diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..57c33ba --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# FUNDING.yml + +github: VigneshVSV \ No newline at end of file diff --git a/.github/workflows/python-publish-pypi.yml b/.github/workflows/python-publish-pypi.yml new file mode 100644 index 0000000..27de753 --- /dev/null +++ b/.github/workflows/python-publish-pypi.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package To Actual PyPI + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: 3.11 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build --wheel + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + diff --git a/.github/workflows/python-publish-testpypi.yml b/.github/workflows/python-publish-testpypi.yml new file mode 100644 index 0000000..b2a88b4 --- /dev/null +++ b/.github/workflows/python-publish-testpypi.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package To Test PyPI + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: 3.11 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build --wheel + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ce5083b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Python Unit Tests + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: 3.11 + + - name: Install dependencies + run: pip install -r tests/requirements.txt + + - name: Run unit tests and generate coverage report + run: | + pip install coverage + coverage run -m unittest discover -s tests -p 'test_*.py' + coverage report -m + + - name: Upload coverage report to codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 933f12c..a887628 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ old/ doc/build doc/source/generated extra-packages - +tests/test-rpc-results.txt +tests/run-unittest.bat +# comment tests until good organisation comes about # vs-code .vscode/ diff --git a/.gitmodules b/.gitmodules index cb83897..b293d9c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ -[submodule "hololinked/system_host/assets/hololinked-server-swagger-api"] - path = hololinked/system_host/assets/hololinked-server-swagger-api - url = https://github.com/VigneshVSV/hololinked-server-swagger-api.git +[submodule "examples"] + path = examples + url = https://github.com/VigneshVSV/hololinked-examples.git +[submodule "doc"] + path = doc + url = https://github.com/VigneshVSV/hololinked-docs.git diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index 7f44635..0000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# .readthedocs.yaml -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - -# Required -version: 2 - -# Set the OS, Python version and other tools you might need -build: - os: ubuntu-22.04 - tools: - python: "3.11" - # You can also specify other tool versions: - # nodejs: "19" - # rust: "1.64" - # golang: "1.19" - -# Build documentation in the "docs/" directory with Sphinx -sphinx: - configuration: doc/source/conf.py - -# Optionally build your docs in additional formats such as PDF and ePub -formats: - - pdf - # - epub - -# Optional but recommended, declare the Python requirements required -# to build your documentation -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -python: - install: - - requirements: doc/source/requirements.txt \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e2afd17 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Security +- cookie auth & its specification in TD + +## [v0.2.1] - 2024-07-21 + +### Added +- properties are now "observable" and push change events when read or written & value has changed +- input & output JSON schema can be specified for actions, where input schema is used for validation of arguments +- TD has read/write properties' forms at thing level, event data schema +- change log +- some unit tests + +### Changed +- events are to specified as descriptors and are not allowed as instance attributes. Specify at class level to + automatically obtain a instance specific event. + +### Fixed +- ``class_member`` argument for properties respected more accurately + +## [v0.1.2] - 2024-06-06 + +### Added +- first public release to pip, docs are the best source to document this release. Checkout commit + [04b75a73c28cab298eefa30746bbb0e06221b81c] and build docs if at all necessary. + + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..bad9e83 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +The document states the plural pronoun "We" or "Community Leaders" etc., +however, currently, I, Vignesh Vaidyanathan, enforce the code of conduct + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, discussions and other contributions +that are not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct currently applies within all contributions made to this repository in all sections +of github (including discussions) & associated repositories like hololinked-examples & those hosted publicly under +the Vignesh Vaidyanathan's space. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +vignesh.vaidyanathan@hololinked.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a649779..320cf06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,112 +1,56 @@ - # Contributing to hololinked First off, thanks for taking the time to contribute! -All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 +All types of contributions are encouraged and valued. > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: > - Star the project -> - Tweet about it -> - Create examples & refer this project in your project's readme -> - Mention the project at local meetups and tell your friends/colleagues +> - Tweet about it or share in social media +> - Create examples & refer this project in your project's readme. I can add your example in my [example repository](https://github.com/VigneshVSV/hololinked-examples) if its really helpful, including use cases in more sophisticated integrations +> - Mention the project at local meetups/conferences and tell your friends/colleagues +> - Donate to cover the costs of maintaining it - -## Table of Contents - -- [I Have a Question](#i-have-a-question) -- [I Want To Contribute](#i-want-to-contribute) - - [Reporting Bugs](#reporting-bugs) - - [Suggesting Enhancements](#suggesting-enhancements) - - [Your First Code Contribution](#your-first-code-contribution) - - [Improving The Documentation](#improving-the-documentation) -- [Styleguides](#styleguides) - - [Commit Messages](#commit-messages) -- [Join The Project Team](#join-the-project-team) +## I Have a Question +Do feel free to reach out to me at vignesh.vaidyanathan@hololinked.dev. I will try my very best to respond. -## I Have a Question +Nevertheless, one may also refer the available how-to section of the [Documentation](https://hololinked.readthedocs.io/en/latest/index.html). +If the documentation is insufficient for any reason including being poorly documented, one may open a new discussion in the [Q&A](https://github.com/VigneshVSV/hololinked/discussions/categories/q-a) section of GitHub discussions. -> If you want to ask a question, one may first refer the available how-to section of the documentation [Documentation](https://hololinked.readthedocs.io/en/latest/index.html). If the documentation is insufficient for any reason including being poorly documented, one may open a new discussion in the Q&A section of GitHub discussions. -It is also advisable to search the internet for answers first. +For questions related to workings of HTTP, JSON schema, basic concepts of python like descriptors, decorators etc., it is also advisable to search the internet for answers first. +For generic questions related to web of things standards or its ideas, I recommend to join web of things [discord](https://discord.com/invite/RJNYJsEgnb) group and [community](https://www.w3.org/community/wot/) group. -If you believe your question might also be a bug, it is best to search for existing [Issues](https://github.com/VigneshVSV/hololinked/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. If an issue is not found: +If you believe your question might also be a bug, you might want to search for existing [Issues](https://github.com/VigneshVSV/hololinked/issues) that might help you. +In case you have found a suitable issue and still need clarification, you can write your question in this issue. If an issue is not found: - Open an [Issue](https://github.com/VigneshVSV/hololinked/issues/new). - Provide as much context as you can about what you're running into. -- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. - -We will then take care of the issue as soon as possible. - -## I Want To Contribute - -> ### Legal Notice -> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. - -### Reporting Bugs - - -#### Before Submitting a Bug Report - -A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. - -- Make sure that you are using the latest version. -- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://hololinked.readthedocs.io/en/latest/index.html). If you are looking for support, you might want to check [this section](#i-have-a-question)). -- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/VigneshVSV/hololinkedissues?q=label%3Abug). -- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. -- Collect information about the bug: - Stack trace (Traceback) - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) - - Version of the interpreter + - Version of python - Possibly your input and the output - - Can you reliably reproduce the issue? And can you also reproduce it with older versions? + - Can you reliably reproduce the issue? + +One may submit a bug report at any level of information, especially if you reached out to me at my email upfront. If you also know how to fix it, lets discuss, once the idea is clear, you can fork and make a pull request. - -#### How Do I Submit a Good Bug Report? +Otherwise, I will then take care of the issue as soon as possible. > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to vignesh.vaidyanathan@hololinked.dev. - - -We use GitHub issues to track bugs and errors. If you run into an issue with the project: - -- Open an [Issue](https://github.com/VigneshVSV/hololinked/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) -- Explain the behavior you would expect and the actual behavior. -- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. -- Provide the information you collected in the previous section. - -Once it's filed: - -- The project team will label the issue accordingly. -- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. -- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). - - -### Suggesting Enhancements - -This section guides you through submitting an enhancement suggestion for hololinked, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. - - -#### Before Submitting an Enhancement - -- Make sure that you are using the latest version. -- Read the [documentation](https://hololinked.readthedocs.io/en/latest/index.html) carefully and find out if the functionality is already covered, maybe by an individual configuration. -- Perform a [search](https://github.com/VigneshVSV/hololinked/discussions/categories/ideas) to see if the enhancement has already been suggested. If it has, add a comment to the existing idea instead of opening a new one. -- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. +## I Want To Contribute - -#### How Do I Submit a Good Enhancement Suggestion? +> ### Legal Notice +> When contributing to this project, you must agree that you have authored 100% of the content or that you have the necessary rights to the content. For example, you copied code from projects with MIT/BSD License. Content from GPL-related licenses may be maintained in a separate repository as an add-on. -Enhancement suggestions are tracked as [GitHub issues](https://github.com/VigneshVSV/hololinked/issues). +Developers are always welcome to contribute to the code base. If you want to tackle any issues, un-existing features, let me know (at my email), I can create some open issues and features which I was never able to solve or did not have the time. You can also suggest what else can be contributed functionally or conceptually or also simply code-refactoring. The lack of issues or features in the [Issues](https://github.com/VigneshVSV/hololinked/issues) section of github does not mean the project is considered feature complete or I dont have ideas what to do next. On the contrary, there is tons of work to do. -- Use a **clear and descriptive title** for the issue to identify the suggestion. -- Provide a **step-by-step description of the suggested enhancement** in as many details as possible. -- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. -- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. -- **Explain why this enhancement would be useful** to most hololinked users. You may also want to point out the other projects that solved it better and which could serve as inspiration. +There are also repositories which can use your skills: +- An [admin client](https://github.com/VigneshVSV/hololinked-portal) in react +- [Documentation](https://github.com/VigneshVSV/hololinked-docs) in sphinx which needs significant improvement in How-To's, beginner level docs which may teach people concepts of data acquisition or IoT, Docstring or API documentation of this repository itself +- [Examples](https://github.com/VigneshVSV/hololinked-examples) in nodeJS, Dashboard/PyQt GUIs or server implementations using this package. Hardware implementations of unexisting examples are also welcome, I can open a directory where people can search for code based on hardware and just download your code. - ## Attribution This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! diff --git a/README.md b/README.md index 4312906..00f2ed3 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,34 @@ -# hololinked +# hololinked - Pythonic Supervisory Control & Data Acquisition / Internet of Things ### Description -For beginners - `hololinked` is a server side pythonic package suited for instrumentation control and data acquisition over network, especially with HTTP. If you have a requirement to control and capture data from your hardware/instrumentation remotely through your network, show the data in a web browser/dashboard, use IoT tools, provide a Qt-GUI or run automated scripts, hololinked can help. One can start small from a single device, and if interested, move ahead to build a bigger system made of individual components. +For beginners - `hololinked` is a server side pythonic package suited for instrumentation control and data acquisition over network, especially with HTTP. If you have a requirement to control and capture data from your hardware/instrumentation, show the data in a browser/dashboard, provide a GUI or run automated scripts, `hololinked` can help. Even for isolated applications or a small lab setup without networking concepts, one can still separate the concerns of the tools that interact with the hardware & the hardware itself.

-For those familiar with RPC & web development - `hololinked` is a ZeroMQ-based Object Oriented RPC toolkit with customizable HTTP end-points. -The main goal is to develop a pythonic & pure python modern package for instrumentation control and data acquisition through network (SCADA), along with "reasonable" HTTP support for web development. - -[![Documentation Status](https://readthedocs.org/projects/hololinked/badge/?version=latest)](https://hololinked.readthedocs.io/en/latest/?badge=latest) [![Maintainability](https://api.codeclimate.com/v1/badges/913f4daa2960b711670a/maintainability)](https://codeclimate.com/github/VigneshVSV/hololinked/maintainability) ![PyPI](https://img.shields.io/pypi/v/hololinked?label=pypi%20package) ![PyPI - Downloads](https://img.shields.io/pypi/dm/hololinked) +For those familiar with RPC & web development - This package is an implementation of a ZeroMQ-based Object Oriented RPC with customizable HTTP end-points. A dual transport in both ZMQ and HTTP is provided to maximize flexibility in data type, serialization and speed, although HTTP is preferred for networked applications. If one is looking for an object oriented approach towards creating components within a control or data acquisition system, or an IoT device, one may consider this package. + +[![Documentation Status](https://readthedocs.org/projects/hololinked/badge/?version=latest)](https://hololinked.readthedocs.io/en/latest/?badge=latest) [![PyPI](https://img.shields.io/pypi/v/hololinked?label=pypi%20package)](https://pypi.org/project/hololinked/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/hololinked)](https://pypistats.org/packages/hololinked) [![codecov](https://codecov.io/gh/VigneshVSV/hololinked/graph/badge.svg?token=JF1928KTFE)](https://codecov.io/gh/VigneshVSV/hololinked) ### To Install From pip - ``pip install hololinked`` -Or, clone the repository and install in develop mode `pip install -e .` for convenience. The conda env hololinked.yml can also help. - +Or, clone the repository (develop branch for latest codebase) and install `pip install .` / `pip install -e .`. The conda env ``hololinked.yml`` can also help to setup all dependencies. ### Usage/Quickstart -`hololinked` is compatible with the [Web of Things](https://www.w3.org/WoT/) recommended pattern for developing hardware/instrumentation control software. Each device or thing can be controlled systematically when their design in software is segregated into properties, actions and events. In object oriented terms for data acquisition: -- properties are validated get-set attributes of the class which may be used to model device settings, hold captured/computed data etc. -- actions are methods which issue commands to the device or run arbitrary python logic. -- events can asynchronously communicate/push data to a client, like alarm messages, streaming captured data etc. +`hololinked` is compatible with the [Web of Things](https://www.w3.org/WoT/) recommended pattern for developing hardware/instrumentation control software. +Each device or thing can be controlled systematically when their design in software is segregated into properties, actions and events. In object oriented terms: +- the hardware is (generally) represented by a class +- properties are validated get-set attributes of the class which may be used to model hardware settings, hold captured/computed data or generic network accessible quantities +- actions are methods which issue commands like connect/disconnect, execute a control routine, start/stop measurement, or run arbitray python logic +- events can asynchronously communicate/push (arbitrary) data to a client (say, a GUI), like alarm messages, streaming measured quantities etc. + +It does not even matter whether you are controlling your hardware locally or remotely, what protocol you use, what is the nature of the client etc., +one has to provide these three interactions with the hardware. In this package, the base class which enables this classification is the `Thing` class. Any class that inherits the `Thing` class +can instantiate properties, actions and events which +become visible to a client in this segragated manner. For example, consider an optical spectrometer, the following code is possible: -In `hololinked`, the base class which enables this classification is the `Thing` class. Any class that inherits the `Thing` class can instantiate properties, actions and events which become visible to a client in this segragated manner. For example, consider an optical spectrometer device, the following code is possible: +> This is a fairly mid-level intro, if you are beginner, for another variant check [How-To](https://hololinked.readthedocs.io/en/latest/howto/index.html) #### Import Statements @@ -31,6 +36,7 @@ In `hololinked`, the base class which enables this classification is the `Thing` from hololinked.server import Thing, Property, action, Event from hololinked.server.properties import String, Integer, Number, List +from seabreeze.spectrometers import Spectrometer # device driver ``` #### Definition of one's own hardware controlling class @@ -38,7 +44,6 @@ from hololinked.server.properties import String, Integer, Number, List subclass from Thing class to "make a network accessible Thing": ```python - class OceanOpticsSpectrometer(Thing): """ OceanOptics spectrometers using seabreeze library. Device is identified by serial number. @@ -103,15 +108,12 @@ class OceanOpticsSpectrometer(Thing): In this case, instead of generating a data container with an internal name, the setter method is called when `integration_time` property is set/written. One might add the hardware device driver (say, supplied by the manufacturer) logic here to apply the property onto the device. In the above example, there is not a way provided by lower level library to read the value from the device, so we store it in a variable after applying it and supply the variable back to the getter method. Normally, one would also want the getter to read from the device directly. -Those familiar with Web of Things (WoT) terminology may note that these properties generate the property affordance schema to become accessible by the [node-wot](https://github.com/eclipse-thingweb/node-wot) client. An example of autogenerated property affordance for `integration_time` is as follows: +Those familiar with Web of Things (WoT) terminology may note that these properties generate the property affordance schema to become accessible by the [node-wot](https://github.com/eclipse-thingweb/node-wot) HTTP(s) client. An example of autogenerated property affordance for `integration_time` is as follows: ```JSON "integration_time": { "title": "integration_time", "description": "integration time of measurement in milliseconds", - "constant": false, - "readOnly": false, - "writeOnly": false, "type": "number", "forms": [{ "href": "https://example.com/spectrometer/integration-time", @@ -125,11 +127,16 @@ Those familiar with Web of Things (WoT) terminology may note that these properti "contentType": "application/json" } ], - "observable": false, "minimum": 0.001 }, ``` -The URL path segment `../spectrometer/..` in href field is taken from the `instance_name` which was specified in the `__init__`. This is a mandatory key word argument to the parent class `Thing` to generate a unique name/id for the instance. One should use URI compatible strings. +If you are not familiar with Web of Things or the term "property affordance", consider the above JSON as a description of +what the property represents and how to interact with it from somewhere else. Such a JSON is both human-readable, yet consumable +by a client provider to create a client object to interact with the property in the way the property demands. You, as the developer, +only need to use the client. + +The URL path segment `../spectrometer/..` in href field is taken from the `instance_name` which was specified in the `__init__`. +This is a mandatory key word argument to the parent class `Thing` to generate a unique name/id for the instance. One should use URI compatible strings. #### Specify methods as actions @@ -150,7 +157,8 @@ class OceanOpticsSpectrometer(Thing): Methods that are neither decorated with action decorator nor acting as getters-setters of properties remain as plain python methods and are **not** accessible on the network. -In WoT Terminology, again, such a method becomes specified as an action affordance: +In WoT Terminology, again, such a method becomes specified as an action affordance (or a description of what the action represents +and how to interact with it): ```JSON "connect": { @@ -164,35 +172,57 @@ In WoT Terminology, again, such a method becomes specified as an action affordan "contentType": "application/json" } ], - "safe": true, - "idempotent": false, - "synchronous": true + "input": { + "type": "object", + "properties": { + "serial_number": { + "type": "string" + } + }, + "additionalProperties": false + } }, - ``` +> input and output schema ("input" field above which describes the argument type `serial_number`) are optional and will be discussed in docs #### Defining and pushing events create a named event using `Event` object that can push any arbitrary data: ```python +class OceanOpticsSpectrometer(Thing): - def __init__(self, instance_name, serial_number, **kwargs): - super().__init__(instance_name=instance_name, serial_number=serial_number, **kwargs) - self.measurement_event = Event(name='intensity-measurement', - URL_path='/intensity/measurement-event')) # only GET HTTP method possible for events - + # only GET HTTP method possible for events + intensity_measurement_event = Event(name='intensity-measurement-event', + URL_path='/intensity/measurement-event', + doc="""event generated on measurement of intensity, + max 30 per second even if measurement is faster.""", + schema=intensity_event_schema) + # schema is optional and will be discussed later, + # assume the intensity_event_schema variable is valid + def capture(self): # not an action, but a plain python method self._run = True + last_time = time.time() while self._run: self._intensity = self.device.intensities( - correct_dark_counts=True, - correct_nonlinearity=True + correct_dark_counts=False, + correct_nonlinearity=False ) - self.measurement_event.push(self._intensity.tolist()) + curtime = datetime.datetime.now() + measurement_timestamp = curtime.strftime('%d.%m.%Y %H:%M:%S.') + '{:03d}'.format( + int(curtime.microsecond /1000)) + if time.time() - last_time > 0.033: # restrict speed to avoid overloading + self.intensity_measurement_event.push({ + "timestamp" : measurement_timestamp, + "value" : self._intensity.tolist() + }) + last_time = time.time() @action(URL_path='/acquisition/start', http_method="POST") def start_acquisition(self): + if self._acquisition_thread is not None and self._acquisition_thread.is_alive(): + return self._acquisition_thread = threading.Thread(target=self.capture) self._acquisition_thread.start() @@ -200,23 +230,41 @@ create a named event using `Event` object that can push any arbitrary data: def stop_acquisition(self): self._run = False ``` +Events can stream live data without polling or push data to a client whose generation in time is uncontrollable. -In WoT Terminology, such an event becomes specified as an event affordance with subprotocol SSE: +In WoT Terminology, such an event becomes specified as an event affordance (or a description of +what the event represents and how to subscribe to it) with subprotocol SSE (HTTP-SSE): ```JSON "intensity_measurement_event": { + "title": "intensity-measurement-event", + "description": "event generated on measurement of intensity, max 30 per second even if measurement is faster.", "forms": [ { - "href": "https://example.com/spectrometer/intensity/measurement-event", - "subprotocol": "sse", - "op": "subscribeevent", - "htv:methodName": "GET", - "contentType": "text/event-stream" + "href": "https://example.com/spectrometer/intensity/measurement-event", + "subprotocol": "sse", + "op": "subscribeevent", + "htv:methodName": "GET", + "contentType": "text/plain" } - ] + ], + "data": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "number" + } + }, + "timestamp": { + "type": "string" + } + } + } } - ``` +> data schema ("data" field above which describes the event payload) are optional and discussed later Although the code is the very familiar & age-old RPC server style, one can directly specify HTTP methods and URL path for each property, action and event. A configurable HTTP Server is already available (from `hololinked.server.HTTPServer`) which redirects HTTP requests to the object according to the specified HTTP API on the properties, actions and events. To plug in a HTTP server: @@ -225,65 +273,51 @@ import ssl, os, logging from multiprocessing import Process from hololinked.server import HTTPServer -def start_https_server(): - # You need to create a certificate on your own - ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS) +if __name__ == '__main__': + ssl_context = ssl.SSLContext(protocol = ssl.PROTOCOL_TLS) ssl_context.load_cert_chain(f'assets{os.sep}security{os.sep}certificate.pem', keyfile = f'assets{os.sep}security{os.sep}key.pem') - - HTTPServer(['spectrometer'], port=8083, ssl_context=ssl_context, - log_level=logging.DEBUG).listen() - - -if __name__ == "__main__": - - Process(target=start_https_server).start() - - OceanOpticsSpectrometer( + + O = OceanOpticsSpectrometer( instance_name='spectrometer', serial_number='S14155', log_level=logging.DEBUG - ).run() - + ) + O.run_with_http_server(ssl_context=ssl_context) ``` -Here one can see the use of `instance_name` and why it turns up in the URL path. -The intention behind specifying HTTP URL paths and methods directly on object's members is to -- eliminate the need to implement a detailed HTTP server (& its API) which generally poses problems in queueing commands issued to instruments -- or, write an additional boiler-plate HTTP to RPC bridge -- or, find a reasonable HTTP-RPC implementation which supports all three of properties, actions and events, yet appeals deeply to the object oriented python world. +Here one can see the use of `instance_name` and why it turns up in the URL path. See the detailed example of the above code [here](https://gitlab.com/hololinked-examples/oceanoptics-spectrometer/-/blob/simple/oceanoptics_spectrometer/device.py?ref_type=heads). -See a list of currently supported features [below](#currently-supported).

-Ultimately, as expected, the redirection from the HTTP side to the object is mediated by ZeroMQ which implements the fully fledged RPC server that queues all the HTTP requests to execute them one-by-one on the hardware/object. The HTTP server can also communicate with the RPC server over ZeroMQ's INPROC (for the non-expert = multithreaded applications, at least in python) or IPC (for the non-expert = multiprocess applications) transport methods. In the example above, IPC is used by default. There is no need for yet another TCP from HTTP to TCP to ZeroMQ transport athough this is also supported.

-Serialization-Deserialization overheads are also already reduced. For example, when pushing an event from the object which gets automatically tunneled as a HTTP SSE or returning a reply for an action from the object, there is no JSON deserialization-serialization overhead when the message passes through the HTTP server. The message is serialized once on the object side but passes transparently through the HTTP server. - -One may use the HTTP API according to one's beliefs (including letting the package auto-generate it), although it is mainly intended for web development and cross platform clients like the interoperable [node-wot](https://github.com/eclipse-thingweb/node-wot) client. The node-wot client is the recommended Javascript client for this package as one can seamlessly plugin code developed from this package to the rest of the IoT tools, protocols & standardizations, or do scripting on the browser or nodeJS. Please check node-wot docs on how to consume [Thing Descriptions](https://www.w3.org/TR/wot-thing-description11) to call actions, read & write properties or subscribe to events. A Thing Description will be automatically generated if absent as shown in JSON examples above or can be supplied manually. -To know more about client side scripting, please look into the documentation [How-To](https://hololinked.readthedocs.io/en/latest/howto/index.html) section. - -##### NOTE - The package is under active development. Contributors welcome. +##### NOTE - The package is under active development. Contributors welcome, please check CONTRIBUTING.md. - [example repository](https://github.com/VigneshVSV/hololinked-examples) - detailed examples for both clients and servers - [helper GUI](https://github.com/VigneshVSV/hololinked-portal) - view & interact with your object's methods, properties and events. + +See a list of currently supported possibilities while using this package [below](#currently-supported). + +> You may use a script deployment/automation tool to remote stop and start servers, in an attempt to remotely control your hardware scripts. + +One may use the HTTP API according to one's beliefs (including letting the package auto-generate it), but it is mainly intended for web development and cross platform clients like the [node-wot](https://github.com/eclipse-thingweb/node-wot) HTTP(s) client. If your plan is to develop a truly networked system, it is recommended to learn more and use [Thing Descriptions](https://www.w3.org/TR/wot-thing-description11) to describe your hardware. A Thing Description will be automatically generated if absent as shown in JSON examples above or can be supplied manually. The node-wot HTTP(s) client will be able to consume such a description, validate it and abstract away the protocol level details so that one can invoke actions, read & write properties or subscribe to events in a technology agnostic manner. In this way, one can plugin code developed from this package to the rest of the IoT/data-acquisition tools, protocols & standardizations. To know more about client side scripting with node-wot, please look into the documentation [How-To](https://hololinked.readthedocs.io/en/latest/howto/clients.html#using-node-wot-http-s-client) section. ### Currently Supported -- indicate HTTP verb & URL path directly on object's methods, properties and events. - control method execution and property write with a custom finite state machine. -- database (Postgres, MySQL, SQLite - based on SQLAlchemy) support for storing and loading properties when object dies and restarts. -- auto-generate Thing Description for Web of Things applications (inaccurate, continuously developed but usable). +- database (Postgres, MySQL, SQLite - based on SQLAlchemy) support for storing and loading properties when the object dies and restarts. +- auto-generate Thing Description for Web of Things applications. - use serializer of your choice (except for HTTP) - MessagePack, JSON, pickle etc. & extend serialization to suit your requirement. HTTP Server will support only JSON serializer to maintain compatibility with node-wot. Default is JSON serializer based on msgspec. - asyncio compatible - async RPC server event-loop and async HTTP Server - write methods in async -- have flexibility in process architecture - run HTTP Server & python object in separate processes or in the same process, serve multiple objects with same HTTP server etc. -- choose from multiple ZeroMQ transport methods. +- choose from multiple ZeroMQ transport methods. Some of the possibilities one can achieve by choosing ZMQ transport methods: + - run HTTP Server & python object in separate processes or the same process + - serve multiple objects with the same HTTP server + - run direct ZMQ-TCP server without HTTP details + - expose only a dashboard or web page on the network without exposing the hardware itself Again, please check examples or the code for explanations. Documentation is being activety improved. ### Currently being worked - improving accuracy of Thing Descriptions -- observable properties and read/write multiple properties through node-wot client -- argument schema validation for actions (you can specify one but its not used) -- credentials for authentication +- cookie credentials for authentication - as a workaround until credentials are supported, use `allowed_clients` argument on HTTP server which restricts access based on remote IP supplied with the HTTP headers. ### Some Day In Future @@ -292,11 +326,4 @@ Again, please check examples or the code for explanations. Documentation is bein ### Contact -Contributors welcome for all my projects related to hololinked including web apps. Please write to my contact email available at my [website](https://hololinked.dev). The contributing file is currently only boilerplate and need not be adhered. - - - - - - - +Contributors welcome for all my projects related to hololinked including web apps. Please write to my contact email available at my [website](https://hololinked.dev). diff --git a/doc b/doc new file mode 160000 index 0000000..68e1be2 --- /dev/null +++ b/doc @@ -0,0 +1 @@ +Subproject commit 68e1be22ce184ec0c28eafb9b8a715a1a6bc9d33 diff --git a/doc/Makefile b/doc/Makefile deleted file mode 100644 index 54c86eb..0000000 --- a/doc/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -clean: - cd "$(BUILDDIR)\html" - del * /s /f /q - cd .. - del "\doctrees" - del * /s /f /q - cd .. - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/make.bat b/doc/make.bat deleted file mode 100644 index 2949e3b..0000000 --- a/doc/make.bat +++ /dev/null @@ -1,58 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build -set DOC_ADDRESS=http://localhost:8000 - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -if "%1" == "server" goto server - -if "%1" == "open-in-chrome" goto open-in-chrome - -if "%1" == "host-doc" goto host-doc - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:server -echo server is hosted at %DOC_ADDRESS%, change port directory in make file if necessary -python -m http.server --directory build\html -goto end - -:open-doc-in-browser -start explorer %DOC_ADDRESS% -goto end - -:host-doc -echo server is hosted at %DOC_ADDRESS%, change port directory in make file if necessary -start explorer %DOC_ADDRESS% -python -m http.server --directory build\html -goto end - -:end -popd diff --git a/doc/source/_static/architecture.drawio.dark.svg b/doc/source/_static/architecture.drawio.dark.svg deleted file mode 100644 index f198eb8..0000000 --- a/doc/source/_static/architecture.drawio.dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -

HTTP Clients
(Web browsers)

HTTP Clients...

Local Desktop

Clients & Scripts

(RPC Clients)

Local Desktop...

Clients & Scripts 

on the network

(RPC Clients)

Clients & Scripts...
HTTP Server
HTTP Server
INPROC Server
INPROC Server
IPC Server
IPC Server

TCP Server

TCP Server
RPC Server
RPC Server
INPROC Server
Eventloop and remote object executor
INPROC Server...
Common INPROC Client
Common INPR...
Recommended Communication
Recommende...
Not Recommended Communication
Not Recommended Com...
Developer should deicde
Developer should...
\ No newline at end of file diff --git a/doc/source/_static/architecture.drawio.light.svg b/doc/source/_static/architecture.drawio.light.svg deleted file mode 100644 index 4b0b2ed..0000000 --- a/doc/source/_static/architecture.drawio.light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -

HTTP Clients
(Web browsers)

HTTP Clients...

Local Desktop

Clients & Scripts

(RPC Clients)

Local Desktop...

Clients & Scripts 

on the network

(RPC Clients)

Clients & Scripts...
HTTP Server
HTTP Server
INPROC Server
INPROC Server
IPC Server
IPC Server

TCP Server

TCP Server
RPC Server
RPC Server
INPROC Server
Eventloop and remote object executor
INPROC Server...
Common INPROC Client
Common INPR...
Recommended Communication
Recommende...
Not Recommended Communication
Not Recommended Com...
Developer should deicde
Developer should...
\ No newline at end of file diff --git a/doc/source/_static/type-definitions-withoutIDL.png b/doc/source/_static/type-definitions-withoutIDL.png deleted file mode 100644 index f0c1e1b..0000000 Binary files a/doc/source/_static/type-definitions-withoutIDL.png and /dev/null differ diff --git a/doc/source/autodoc/client/index.rst b/doc/source/autodoc/client/index.rst deleted file mode 100644 index 225cb94..0000000 --- a/doc/source/autodoc/client/index.rst +++ /dev/null @@ -1,6 +0,0 @@ -``ObjectProxy`` -=============== - -.. autoclass:: hololinked.client.ObjectProxy - :members: - :show-inheritance: diff --git a/doc/source/autodoc/index.rst b/doc/source/autodoc/index.rst deleted file mode 100644 index f96f0db..0000000 --- a/doc/source/autodoc/index.rst +++ /dev/null @@ -1,50 +0,0 @@ -.. _apiref: - -.. |br| raw:: html - -
- -API Reference -============= - -``hololinked.server`` ---------------------- - -.. toctree:: - :maxdepth: 1 - - server/thing/index - server/properties/index - server/action - server/events - server/http_server/index - server/serializers - server/database/index - server/eventloop - server/zmq_message_brokers/index - server/configuration - server/dataclasses - server/enumerations - - - -``hololinked.client`` ---------------------- - -.. toctree:: - :maxdepth: 1 - - client/index - - -.. ``hololinked.system_host`` -.. -------------------------- - -.. .. toctree:: -.. :maxdepth: 1 - -.. server/system_host/index - - - - diff --git a/doc/source/autodoc/server/action.rst b/doc/source/autodoc/server/action.rst deleted file mode 100644 index 0bc9acb..0000000 --- a/doc/source/autodoc/server/action.rst +++ /dev/null @@ -1,4 +0,0 @@ -actions -======= - -.. autofunction:: hololinked.server.action.action \ No newline at end of file diff --git a/doc/source/autodoc/server/configuration.rst b/doc/source/autodoc/server/configuration.rst deleted file mode 100644 index b4e086c..0000000 --- a/doc/source/autodoc/server/configuration.rst +++ /dev/null @@ -1,6 +0,0 @@ -``Configuration`` -================= - -.. autoclass:: hololinked.server.config.Configuration - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/database/baseDB.rst b/doc/source/autodoc/server/database/baseDB.rst deleted file mode 100644 index c8586fb..0000000 --- a/doc/source/autodoc/server/database/baseDB.rst +++ /dev/null @@ -1,15 +0,0 @@ -BaseDB ------- - -.. autoclass:: hololinked.server.database.BaseDB - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.database.BaseSyncDB - :members: - :show-inheritance: - -.. .. autoclass:: hololinked.server.database.BaseAsyncDB -.. :members: -.. :show-inheritance: - diff --git a/doc/source/autodoc/server/database/helpers.rst b/doc/source/autodoc/server/database/helpers.rst deleted file mode 100644 index a03caf0..0000000 --- a/doc/source/autodoc/server/database/helpers.rst +++ /dev/null @@ -1,6 +0,0 @@ -helpers -------- - -.. autoclass:: hololinked.server.database.batch_db_commit - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/database/index.rst b/doc/source/autodoc/server/database/index.rst deleted file mode 100644 index 9eaeca7..0000000 --- a/doc/source/autodoc/server/database/index.rst +++ /dev/null @@ -1,13 +0,0 @@ -database -======== - -.. autoclass:: hololinked.server.database.ThingDB - :members: - :show-inheritance: - -.. toctree:: - :hidden: - :maxdepth: 1 - - helpers - baseDB \ No newline at end of file diff --git a/doc/source/autodoc/server/dataclasses.rst b/doc/source/autodoc/server/dataclasses.rst deleted file mode 100644 index 67157f6..0000000 --- a/doc/source/autodoc/server/dataclasses.rst +++ /dev/null @@ -1,42 +0,0 @@ -.. _apiref: - -.. |br| raw:: html - -
- - -data classes -============ - -The following is a list of all dataclasses used to store information on the exposed -resources on the network. These classese are generally not for consumption by the package-end-user. - - -.. autoclass:: hololinked.server.data_classes.RemoteResourceInfoValidator - :members: to_dataclass - :show-inheritance: - -|br| - -.. autoclass:: hololinked.server.data_classes.RemoteResource - :members: - :show-inheritance: - -|br| - -.. autoclass:: hololinked.server.data_classes.HTTPResource - :members: - :show-inheritance: - -|br| - -.. autoclass:: hololinked.server.data_classes.RPCResource - :members: - :show-inheritance: - -|br| - -.. autoclass:: hololinked.server.data_classes.ServerSentEvent - :members: - :show-inheritance: - diff --git a/doc/source/autodoc/server/enumerations.rst b/doc/source/autodoc/server/enumerations.rst deleted file mode 100644 index 960664f..0000000 --- a/doc/source/autodoc/server/enumerations.rst +++ /dev/null @@ -1,12 +0,0 @@ -enumerations ------------- - -.. autoclass:: hololinked.server.constants.HTTP_METHODS() - -.. autoclass:: hololinked.server.constants.Serializers() - -.. autoclass:: hololinked.server.constants.ZMQ_PROTOCOLS() - - - - diff --git a/doc/source/autodoc/server/eventloop.rst b/doc/source/autodoc/server/eventloop.rst deleted file mode 100644 index 88a5023..0000000 --- a/doc/source/autodoc/server/eventloop.rst +++ /dev/null @@ -1,14 +0,0 @@ -``EventLoop`` -============= - -.. autoclass:: hololinked.server.eventloop.EventLoop() - :members: instance_name, things - :show-inheritance: - -.. automethod:: hololinked.server.eventloop.EventLoop.__init__ - -.. automethod:: hololinked.server.eventloop.EventLoop.run - -.. automethod:: hololinked.server.eventloop.EventLoop.exit - -.. automethod:: hololinked.server.eventloop.EventLoop.get_async_loop \ No newline at end of file diff --git a/doc/source/autodoc/server/events.rst b/doc/source/autodoc/server/events.rst deleted file mode 100644 index 0a013c7..0000000 --- a/doc/source/autodoc/server/events.rst +++ /dev/null @@ -1,6 +0,0 @@ -events -====== - -.. autoclass:: hololinked.server.events.Event - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/http_server/base_handler.rst b/doc/source/autodoc/server/http_server/base_handler.rst deleted file mode 100644 index 022c501..0000000 --- a/doc/source/autodoc/server/http_server/base_handler.rst +++ /dev/null @@ -1,6 +0,0 @@ -``BaseHandler`` -=============== - -.. autoclass:: hololinked.server.handlers.BaseHandler - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/http_server/event_handler.rst b/doc/source/autodoc/server/http_server/event_handler.rst deleted file mode 100644 index 259eb9b..0000000 --- a/doc/source/autodoc/server/http_server/event_handler.rst +++ /dev/null @@ -1,6 +0,0 @@ -``EventHandler`` -================ - -.. autoclass:: hololinked.server.handlers.EventHandler - :members: - :show-inheritance: diff --git a/doc/source/autodoc/server/http_server/index.rst b/doc/source/autodoc/server/http_server/index.rst deleted file mode 100644 index 12016d3..0000000 --- a/doc/source/autodoc/server/http_server/index.rst +++ /dev/null @@ -1,15 +0,0 @@ -``HTTPServer`` -============== - -.. autoclass:: hololinked.server.HTTPServer.HTTPServer - :members: - :show-inheritance: - - -.. toctree:: - :maxdepth: 1 - :hidden: - - rpc_handler - event_handler - base_handler \ No newline at end of file diff --git a/doc/source/autodoc/server/http_server/rpc_handler.rst b/doc/source/autodoc/server/http_server/rpc_handler.rst deleted file mode 100644 index 376e630..0000000 --- a/doc/source/autodoc/server/http_server/rpc_handler.rst +++ /dev/null @@ -1,6 +0,0 @@ -``RPCHandler`` -============== - -.. autoclass:: hololinked.server.handlers.RPCHandler - :members: - :show-inheritance: diff --git a/doc/source/autodoc/server/properties/helpers.rst b/doc/source/autodoc/server/properties/helpers.rst deleted file mode 100644 index d3c7b82..0000000 --- a/doc/source/autodoc/server/properties/helpers.rst +++ /dev/null @@ -1,4 +0,0 @@ -helpers -======= - -.. autofunction:: hololinked.param.parameterized.depends_on \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/index.rst b/doc/source/autodoc/server/properties/index.rst deleted file mode 100644 index 3068ed0..0000000 --- a/doc/source/autodoc/server/properties/index.rst +++ /dev/null @@ -1,30 +0,0 @@ -properties -========== - -.. toctree:: - :hidden: - :maxdepth: 1 - - types/index - parameterized - helpers - -.. autoclass:: hololinked.server.property.Property() - :members: - :show-inheritance: - -.. automethod:: hololinked.server.property.Property.validate_and_adapt - -.. automethod:: hololinked.server.property.Property.getter - -.. automethod:: hololinked.server.property.Property.setter - -.. automethod:: hololinked.server.property.Property.deleter - - -A few notes: - -* The default value of ``Property`` (first argument to constructor) is owned by the Property instance and not the object where the property is attached. -* The value of a constant can still be changed in code by temporarily overriding the value of this attribute or ``edit_constant`` context manager. - - diff --git a/doc/source/autodoc/server/properties/parameterized.rst b/doc/source/autodoc/server/properties/parameterized.rst deleted file mode 100644 index a722c8c..0000000 --- a/doc/source/autodoc/server/properties/parameterized.rst +++ /dev/null @@ -1,6 +0,0 @@ -``Parameterized`` -================= - -.. autoclass:: hololinked.param.parameterized.Parameterized - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/boolean.rst b/doc/source/autodoc/server/properties/types/boolean.rst deleted file mode 100644 index afd4c51..0000000 --- a/doc/source/autodoc/server/properties/types/boolean.rst +++ /dev/null @@ -1,6 +0,0 @@ -``Boolean`` -=========== - -.. autoclass:: hololinked.server.properties.Boolean - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/class_selector.rst b/doc/source/autodoc/server/properties/types/class_selector.rst deleted file mode 100644 index d5cf60f..0000000 --- a/doc/source/autodoc/server/properties/types/class_selector.rst +++ /dev/null @@ -1,7 +0,0 @@ -``ClassSelector`` -================= - - -.. autoclass:: hololinked.server.properties.ClassSelector - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/file_system.rst b/doc/source/autodoc/server/properties/types/file_system.rst deleted file mode 100644 index 8b1b325..0000000 --- a/doc/source/autodoc/server/properties/types/file_system.rst +++ /dev/null @@ -1,18 +0,0 @@ -``Filename``, ``Foldername``, ``Path``, ``FileSelector`` -======================================================== - -.. autoclass:: hololinked.server.properties.Filename - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.properties.Foldername - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.properties.Path - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.properties.FileSelector - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/index.rst b/doc/source/autodoc/server/properties/types/index.rst deleted file mode 100644 index 76e00a3..0000000 --- a/doc/source/autodoc/server/properties/types/index.rst +++ /dev/null @@ -1,18 +0,0 @@ -builtin-types -------------- - -These are the predefined parameter types. Others can be custom-defined by inheriting from ``Property`` base class -or any of the following classes. - -.. toctree:: - :maxdepth: 1 - - string - number - boolean - iterables - selector - class_selector - file_system - typed_list - typed_dict \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/iterables.rst b/doc/source/autodoc/server/properties/types/iterables.rst deleted file mode 100644 index 6a153e5..0000000 --- a/doc/source/autodoc/server/properties/types/iterables.rst +++ /dev/null @@ -1,10 +0,0 @@ -``List``, ``Tuple`` -=================== - -.. autoclass:: hololinked.server.properties.List - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.properties.Tuple - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/number.rst b/doc/source/autodoc/server/properties/types/number.rst deleted file mode 100644 index 5b27f88..0000000 --- a/doc/source/autodoc/server/properties/types/number.rst +++ /dev/null @@ -1,10 +0,0 @@ -``Number``, ``Integer`` -======================= - -.. autoclass:: hololinked.server.properties.Number - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.properties.Integer - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/selector.rst b/doc/source/autodoc/server/properties/types/selector.rst deleted file mode 100644 index f1636c3..0000000 --- a/doc/source/autodoc/server/properties/types/selector.rst +++ /dev/null @@ -1,10 +0,0 @@ -``Selector``, ``TupleSelector`` -=============================== - -.. autoclass:: hololinked.server.properties.Selector - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.properties.TupleSelector - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/string.rst b/doc/source/autodoc/server/properties/types/string.rst deleted file mode 100644 index 3337118..0000000 --- a/doc/source/autodoc/server/properties/types/string.rst +++ /dev/null @@ -1,6 +0,0 @@ -``String`` -========== - -.. autoclass:: hololinked.server.properties.String - :members: - :show-inheritance: diff --git a/doc/source/autodoc/server/properties/types/typed_dict.rst b/doc/source/autodoc/server/properties/types/typed_dict.rst deleted file mode 100644 index bdfd288..0000000 --- a/doc/source/autodoc/server/properties/types/typed_dict.rst +++ /dev/null @@ -1,6 +0,0 @@ -``TypedDict`` -============= - -.. autoclass:: hololinked.server.properties.TypedDict - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/properties/types/typed_list.rst b/doc/source/autodoc/server/properties/types/typed_list.rst deleted file mode 100644 index b808ff6..0000000 --- a/doc/source/autodoc/server/properties/types/typed_list.rst +++ /dev/null @@ -1,6 +0,0 @@ -``TypedList`` -============= - -.. autoclass:: hololinked.server.properties.TypedList - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/schema.rst b/doc/source/autodoc/server/schema.rst deleted file mode 100644 index 76d67eb..0000000 --- a/doc/source/autodoc/server/schema.rst +++ /dev/null @@ -1,41 +0,0 @@ -Thing Description Schema ------------------------- - -.. autoclass:: hololinked.server.td.ThingDescription - :members: - :show-inheritance: - - -.. collapse:: Schema - - .. autoclass:: hololinked.server.td.Schema - :members: - :show-inheritance: - - -.. collapse:: InteractionAffordance - - .. autoclass:: hololinked.server.td.InteractionAffordance - :members: - :show-inheritance: - - -.. collapse:: PropertyAffordance - - .. autoclass:: hololinked.server.td.PropertyAffordance - :members: - :show-inheritance: - - -.. collapse:: ActionAffordance - - .. autoclass:: hololinked.server.td.ActionAffordance - :members: - :show-inheritance: - - -.. collapse:: EventAffordance - - .. autoclass:: hololinked.server.td.EventAffordance - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/serializers.rst b/doc/source/autodoc/server/serializers.rst deleted file mode 100644 index 255c379..0000000 --- a/doc/source/autodoc/server/serializers.rst +++ /dev/null @@ -1,36 +0,0 @@ -serializers ------------ - -.. autoclass:: hololinked.server.serializers.BaseSerializer - :members: - :show-inheritance: - -.. collapse:: JSONSerializer - - .. autoclass:: hololinked.server.serializers.JSONSerializer - :members: - :show-inheritance: - -.. collapse:: MsgpackSerializer - - .. autoclass:: hololinked.server.serializers.MsgpackSerializer - :members: - :show-inheritance: - -.. collapse:: SerpentSerializer - - .. autoclass:: hololinked.server.serializers.SerpentSerializer - :members: - :show-inheritance: - -.. collapse:: PickleSerializer - - .. autoclass:: hololinked.server.serializers.PickleSerializer - :members: - :show-inheritance: - -.. collapse:: PythonBuiltinJSONSerializer - - .. autoclass:: hololinked.server.serializers.PythonBuiltinJSONSerializer - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/system_host/index.rst b/doc/source/autodoc/server/system_host/index.rst deleted file mode 100644 index e651655..0000000 --- a/doc/source/autodoc/server/system_host/index.rst +++ /dev/null @@ -1,9 +0,0 @@ -SystemHost -========== - -.. autofunction:: hololinked.system_host.create_system_host - -.. autoclass:: hololinked.system_host.handlers.SystemHostHandler - :members: - :show-inheritance: - diff --git a/doc/source/autodoc/server/thing/index.rst b/doc/source/autodoc/server/thing/index.rst deleted file mode 100644 index d48a0c3..0000000 --- a/doc/source/autodoc/server/thing/index.rst +++ /dev/null @@ -1,41 +0,0 @@ -``Thing`` -========= - -.. autoclass:: hololinked.server.thing.Thing() - :members: instance_name, logger, state, rpc_serializer, json_serializer, - event_publisher, - :show-inheritance: - -.. automethod:: hololinked.server.thing.Thing.__init__ - -.. attribute:: Thing.logger_remote_access - :type: Optional[bool] - - set False to prevent access of logs of logger remotely - -.. attribute:: Thing.state_machine - :type: Optional[hololinked.server.state_machine.StateMachine] - - initialize state machine for controlling method/action execution and property writes - -.. attribute:: Thing.use_default_db - :type: Optional[bool] - - set True to create a default SQLite database. Mainly used for storing properties and autoloading them when the object - dies and restarts. - -.. automethod:: hololinked.server.thing.Thing.get_thing_description - -.. automethod:: hololinked.server.thing.Thing.run_with_http_server - -.. automethod:: hololinked.server.thing.Thing.run - -.. automethod:: hololinked.server.thing.Thing.exit - -.. toctree:: - :maxdepth: 1 - :hidden: - - state_machine - network_handler - thing_meta \ No newline at end of file diff --git a/doc/source/autodoc/server/thing/network_handler.rst b/doc/source/autodoc/server/thing/network_handler.rst deleted file mode 100644 index 22297fb..0000000 --- a/doc/source/autodoc/server/thing/network_handler.rst +++ /dev/null @@ -1,27 +0,0 @@ -``RemoteAccessHandler`` -======================= - -.. autoclass:: hololinked.server.logger.RemoteAccessHandler() - :show-inheritance: - -.. automethod:: hololinked.server.logger.RemoteAccessHandler.__init__ - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.stream_interval - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.debug_logs - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.warn_logs - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.info_logs - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.error_logs - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.critical_logs - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.execution_logs - -.. autoattribute:: hololinked.server.logger.RemoteAccessHandler.maxlen - -.. automethod:: hololinked.server.logger.RemoteAccessHandler.push_events - -.. automethod:: hololinked.server.logger.RemoteAccessHandler.stop_events \ No newline at end of file diff --git a/doc/source/autodoc/server/thing/state_machine.rst b/doc/source/autodoc/server/thing/state_machine.rst deleted file mode 100644 index 88d17a9..0000000 --- a/doc/source/autodoc/server/thing/state_machine.rst +++ /dev/null @@ -1,19 +0,0 @@ -``StateMachine`` -================ - -.. autoclass:: hololinked.server.state_machine.StateMachine() - :members: valid, on_enter, on_exit, states, initial_state, machine, current_state - :show-inheritance: - -.. automethod:: hololinked.server.state_machine.StateMachine.__init__ - -.. automethod:: hololinked.server.state_machine.StateMachine.set_state - -.. automethod:: hololinked.server.state_machine.StateMachine.get_state - -.. automethod:: hololinked.server.state_machine.StateMachine.has_object - -.. note:: - The condition whether to execute a certain method or parameter write in a certain - state is checked by the ``EventLoop`` class and not this class. This class only provides - the information and handles set state logic. diff --git a/doc/source/autodoc/server/thing/thing_meta.rst b/doc/source/autodoc/server/thing/thing_meta.rst deleted file mode 100644 index effddd9..0000000 --- a/doc/source/autodoc/server/thing/thing_meta.rst +++ /dev/null @@ -1,7 +0,0 @@ -``ThingMeta`` -============= - -.. autoclass:: hololinked.server.thing.ThingMeta() - :members: properties - :show-inheritance: - diff --git a/doc/source/autodoc/server/zmq_message_brokers/base_zmq.rst b/doc/source/autodoc/server/zmq_message_brokers/base_zmq.rst deleted file mode 100644 index 2656d27..0000000 --- a/doc/source/autodoc/server/zmq_message_brokers/base_zmq.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. |br| raw:: html - -
- - -Base ZMQ -======== - -.. autoclass:: hololinked.server.zmq_message_brokers.BaseZMQ - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.BaseSyncZMQ - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.BaseAsyncZMQ - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.BaseZMQServer - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.BaseAsyncZMQServer - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.BaseZMQClient - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/zmq_message_brokers/event.rst b/doc/source/autodoc/server/zmq_message_brokers/event.rst deleted file mode 100644 index fcdf71d..0000000 --- a/doc/source/autodoc/server/zmq_message_brokers/event.rst +++ /dev/null @@ -1,14 +0,0 @@ -Event Handlers -============== - -.. autoclass:: hololinked.server.zmq_message_brokers.EventPublisher - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.EventConsumer - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.AsyncEventConsumer - :members: - :show-inheritance: \ No newline at end of file diff --git a/doc/source/autodoc/server/zmq_message_brokers/index.rst b/doc/source/autodoc/server/zmq_message_brokers/index.rst deleted file mode 100644 index 1e01337..0000000 --- a/doc/source/autodoc/server/zmq_message_brokers/index.rst +++ /dev/null @@ -1,33 +0,0 @@ -ZMQ message brokers -=================== - -``hololinked`` uses ZMQ under the hood to implement a RPC server. All requests, either coming through a HTTP -Server (from a HTTP client/web browser) or an RPC client are routed via the RPC Server to queue them before execution. - -Since a RPC client is available in ``hololinked`` (or will be made available), it is suggested to use the HTTP -server for web development practices (like REST-similar endpoints) and not for RPC purposes. The following picture -summarizes how messages are routed to the ``RemoteObject``. - -.. image:: ../../../_static/architecture.drawio.light.svg - :class: only-light - -.. image:: ../../../_static/architecture.drawio.dark.svg - :class: only-dark - - -The message brokers are divided to client and server types. Servers recieve a message before replying & clients -initiate message requests. - -See documentation of ``RPCServer`` for details. - -.. toctree:: - :maxdepth: 1 - - base_zmq - zmq_server - rpc_server - zmq_client - event - - - diff --git a/doc/source/autodoc/server/zmq_message_brokers/rpc_server.rst b/doc/source/autodoc/server/zmq_message_brokers/rpc_server.rst deleted file mode 100644 index 23fda29..0000000 --- a/doc/source/autodoc/server/zmq_message_brokers/rpc_server.rst +++ /dev/null @@ -1,13 +0,0 @@ -.. |br| raw:: html - -
- - -RPC Server -========== - - -.. autoclass:: hololinked.server.zmq_message_brokers.RPCServer - :members: - :show-inheritance: - diff --git a/doc/source/autodoc/server/zmq_message_brokers/zmq_client.rst b/doc/source/autodoc/server/zmq_message_brokers/zmq_client.rst deleted file mode 100644 index 0cf83b2..0000000 --- a/doc/source/autodoc/server/zmq_message_brokers/zmq_client.rst +++ /dev/null @@ -1,16 +0,0 @@ -.. |br| raw:: html - -
- - -ZMQ Clients -=========== - -.. autoclass:: hololinked.server.zmq_message_brokers.SyncZMQClient - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.MessageMappedZMQClientPool - :members: - :show-inheritance: - diff --git a/doc/source/autodoc/server/zmq_message_brokers/zmq_server.rst b/doc/source/autodoc/server/zmq_message_brokers/zmq_server.rst deleted file mode 100644 index 3390224..0000000 --- a/doc/source/autodoc/server/zmq_message_brokers/zmq_server.rst +++ /dev/null @@ -1,19 +0,0 @@ -.. |br| raw:: html - -
- - -ZMQ Servers -=========== - -.. autoclass:: hololinked.server.zmq_message_brokers.AsyncZMQServer - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.AsyncPollingZMQServer - :members: - :show-inheritance: - -.. autoclass:: hololinked.server.zmq_message_brokers.ZMQServerPool - :members: - :show-inheritance: diff --git a/doc/source/benchmark/index.rst b/doc/source/benchmark/index.rst deleted file mode 100644 index 7184120..0000000 --- a/doc/source/benchmark/index.rst +++ /dev/null @@ -1,2 +0,0 @@ -Noteworthy Benchmarks -===================== \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py deleted file mode 100644 index e21bb15..0000000 --- a/doc/source/conf.py +++ /dev/null @@ -1,80 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import os -import sys -sys.path.insert(0, os.path.abspath(f'..{os.sep}..')) - - -# -- Project information ----------------------------------------------------- - -project = 'hololinked' -copyright = '2024, Vignesh Venkatasubramanian Vaidyanathan' -author = 'Vignesh Venkatasubramanian Vaidyanathan' - -# The full version, including alpha/beta/rc tags -release = '0.1.2' - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.duration', - 'sphinx_copybutton', - 'sphinx_toolbox.collapse', - 'numpydoc' -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'pydata_sphinx_theme' - -html_sidebars = { - "**": ["sidebar-nav-bs"] -} - -html_theme_options = { - "secondary_sidebar_items": { - "**" : ["page-toc", "sourcelink"], - }, - "navigation_with_keys" : True -} - -pygments_style = 'vs' - -# 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'] - -numpydoc_show_class_members = False - -autodoc_member_order = 'bysource' - -today_fmt = '%d.%m.%Y %H:%M' - - diff --git a/doc/source/development_notes.rst b/doc/source/development_notes.rst deleted file mode 100644 index 74c9514..0000000 --- a/doc/source/development_notes.rst +++ /dev/null @@ -1,97 +0,0 @@ -.. |module-highlighted| replace:: ``hololinked`` - -.. |br| raw:: html - -
- - -.. _note: - -Development Notes -================= - -|module-highlighted| is fundamentally a Object Oriented ZeroMQ RPC with control over the attributes, methods -and events that are exposed on the network. Nevertheless, a non-trivial support for HTTP exists in an attempt to cast -atleast certain aspects of instrumentation control & data-acquisition for web development practices, without having to -explicitly implement a HTTP server. The following is possible with significantly lesser code: - -* |module-highlighted| gives the freedom to choose the HTTP request method & end-point URL desirable for - property/attribute, method and event -* All HTTP requests will be automatically queued and executed serially by the RPC server unless threaded or - made async by the developer -* JSON serialization-deserialization overheads while tunneling HTTP requests through the RPC server - are reduced to a minimum. -* Events pushed by the object will be automatically tunneled as HTTP server sent events - -Further web request handlers may be modified to change headers, authentication etc. or add additional -endpoints which may cast resources to REST-like while leaving the RPC details to the package. One uses exposed object -members as follows: - -* properties can be used to model settings of instrumentation (both hardware and software-only), - general class/instance attributes, hold captured & computed data -* methods can be used to issue commands to instruments like start and stop acquisition, connect/disconnect etc. -* events can be used to push measured data, create alerts/alarms, inform availability of certain type of data etc. - -Verb like URLs may be used for methods (acts like HTTP-RPC although ZeroMQ mediates this) & noun-like URLs may be used -for properties and events. Further, HTTP request methods may be mapped as follows: - -.. list-table:: - :header-rows: 1 - - * - HTTP request verb/method - - property - - action/remote method - - event - * - GET - - read property value |br| (read a setting's value, fetch measured data, physical quantities) - - run method which gives a return value with useful data |br| (which may be difficult or illogical as a ``Property``) - - stream measured data immediately when available instead of fetching every time - * - POST - - add dynamic properties with certain settings |br| (add a dynamic setting or data type etc. for which the logic is already factored in code) - - run python logic, methods that connect/disconnect or issue commands to instruments (RPC) - - not applicable - * - PUT - - write property value |br| (modify a setting and apply it onto the device) - - change value of a resource which is difficult to factor into a property - - not applicable - * - DELETE - - remove a dynamic property |br| (remove a setting or data type for which the logic is already factored into the code) - - developer's interpretation - - not applicable - * - PATCH - - change settings of a property |br| (change the rules of how a setting can be modified and applied, how a measured data can be stored etc.) - - change partial value of a resource which is difficult to factor into a property or change settings of a property with custom logic - - not applicable - -If you dont agree with the table above, use `Thing Description ` -standard instead, which is pretty close. Considering an example device like a spectrometer, the table above may dictate the following: - -.. list-table:: - :header-rows: 1 - - * - HTTP request verb/method - - property - - remote method - - event - * - GET - - get integration time - - get accumulated dictionary of measurement settings - - stream measured spectrum - * - POST - - - - connect, disconnect, start and stop acquisition - - - * - PUT - - set integration time onto device - - - - - * - PATCH - - edit integration time bound values - - - - - - -Further, plain RPC calls directly through object proxy are possible without the details of HTTP. This is directly mediated -by ZeroMQ. - - diff --git a/doc/source/examples/index.rst b/doc/source/examples/index.rst deleted file mode 100644 index 7f75bad..0000000 --- a/doc/source/examples/index.rst +++ /dev/null @@ -1,27 +0,0 @@ -Examples -======== - -Remote Objects --------------- - -Beginner's example -__________________ - -These examples are for absolute beginners into the world of data-acquisition - -.. toctree:: - server/spectrometer/index - -The code is hosted at the repository `hololinked-examples `_. -Consider also installing - -* a JSON preview tool for your browser like `Chrome JSON Viewer `_. -* `hololinked-portal `_ to have an web-interface to interact with RemoteObjects (after you can run your example object) -* `hoppscotch `_ or `postman `_ - -Web Development ---------------- - -Some browser based client examples based on ReactJS are hosted at -`hololinked.dev `_ - diff --git a/doc/source/examples/server/energy-meter/index.rst b/doc/source/examples/server/energy-meter/index.rst deleted file mode 100644 index 0f27cbf..0000000 --- a/doc/source/examples/server/energy-meter/index.rst +++ /dev/null @@ -1,2 +0,0 @@ -Energy Meter & Timeseries Data -============================== \ No newline at end of file diff --git a/doc/source/examples/server/spectrometer/index.rst b/doc/source/examples/server/spectrometer/index.rst deleted file mode 100644 index 2533cd6..0000000 --- a/doc/source/examples/server/spectrometer/index.rst +++ /dev/null @@ -1,638 +0,0 @@ -.. |module| replace:: hololinked - -.. |module-highlighted| replace:: ``hololinked`` - -.. |remote-paramerter-import-highlighted| replace:: ``hololinked.server.remote_parameters`` - -.. |br| raw:: html - -
- -Spectrometer -============ - -Consider you have an optical spectrometer & you need the following options to control it: - -* connect & disconnect from the instrument -* capture spectrum data -* change measurement settings like integration time, trigger mode etc. - -We start by creating an object class as a sublcass of ``RemoteObject``. -In this example, OceanSight USB2000+ spectrometer is used, which has a python high level wrapper -called `seabreeze `_. - -.. code-block:: python - :caption: device.py - - from hololinked.server import RemoteObject - from seabreeze.spectrometers import Spectrometer - - - class OceanOpticsSpectrometer(RemoteObject): - """ - Connect to OceanOptics spectrometers using seabreeze library by specifying serial number. - """ - -The spectrometers are identified by a serial number, which needs to be a string. By default, lets allow this `serial_number` to be ``None`` when invalid, -passed in via the constructor & autoconnect when the object is initialised with a valid serial number. To ensure that the serial number is complied to a string whenever accessed by a client -on the network, we need to use a parameter type defined in |remote-paramerter-import-highlighted|. By default, |module-highlighted| -defines a few types of remote parameters/attributes like Number (float or int), String, Date etc. -The exact meaning and definitions are found in the :ref:`API Reference ` and is copied from `param `_. - - -.. code-block:: python - :emphasize-lines: 2 - - ... - from hololinked.server.remote_parameters import String - - class OceanOpticsSpectrometer(RemoteObject): - ... - - serial_number = String(default=None, allow_None=True, URL_path='/serial-number', - doc='serial number of the spectrometer to connect/control') - - model = String(default=None, allow_None=True, URL_path='/model', - doc='the model of the connected spectrometer') - - def __init__(self, serial_number : str, **kwargs): - super().__init__(serial_number=serial_number, **kwargs) - if serial_number is not None: - self.connect() - self._acquisition_thread = None - self._running = False - - -Each such parameter (like String, Number etc.) have their own list of arguments as seen above (``default``, ``allow_None``, ``doc``). -Here we state that the default value of `serial_number` is ``None``, i.e. it accepts ``None`` although defined as a String and the URL_path where it should -be accessible when served by a HTTP Server is '/serial-number'. Normally, such a parameter, when having a valid (non-None) value, -will be forced to contain a string only. Moreover, although these parameters are defined at class-level (looks like a class -attribute), unless the ``class_member=True`` is set on the parameter, its an instance attribute. This is due to the -python `descriptor `_ protocol. The same explanation applies to the `model` parameter, which -will be set from within the object during connection. - -The `URL_path` is interpreted as follows: First and foremost, you need to spawn a ``HTTPServer``. Second, such a HTTP Server -must talk to the OceanOpticsSpectrometer instance. To achieve this, the ``RemoteObject`` is instantiated with a specific -``instance_name`` and the HTTP server is spawn with an argument containing the instance_name. (We still did not implement -any methods like connect/disconnect etc.). When the HTTPServer can talk with the ``RemoteObject`` instance, the parameter becomes available at the stated `URL_path` along -with the prefix of the HTTP Server domain name and object instance name. - -.. code-block:: python - :emphasize-lines: 2 - - ... - from hololinked.server import HTTPServer - import logging - - - class OceanOpticsSpectrometer(RemoteObject): - ... - - model = String(default=None, allow_None=True, URL_path='/model', - doc='the model of the connected spectrometer') - - if __name__ == '__main__': - H = HTTPServer(consumers=['spectrometer/ocean-optics/USB2000-plus'], port=8083, log_level=logging.DEBUG) - H.start(block=False) # creates a new process - - O = OceanOpticsSpectrometer( - instance_name='spectrometer/ocean-optics/USB2000-plus', - # a name for the instance of the object, since the same object can be instantiated with - # a different name to control a different spectrometer - serial_number='USB2+H15897', - log_level=logging.DEBUG, - ) - O.run() - -To construct the full `URL_path`, the format is |br| -`https://{domain name}/{instance name}/{parameter URL path}`, which gives |br| -`https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/serial-number` |br| for the `serial_number`. - -If your PC has a domain name, you can also use the domain name instead of `localhost`. Since the `instance_name` partipates as a prefix in the `URL path`, -it is recommended to use a slash separated ('/') name complying to URL standards. -A name with 0 slashes are also accepted & a leading slash will always be inserted after the domain name. Therefore, its not necessary -to start the `instance_name` with a slash unlike the `URL_path`. - -To access the `serial_number`, once the example starts without errors, type the URL in the web browser to get a reply like the following: - -.. code-block:: JSON - - { - "responseStatusCode" : 200, - "returnValue" : "USB2+H15897", - "state" : null - } - -The `returnValue` field contains the value obtained by running the python method, in this case python attribute -getter of `serial_number`. The `state` field refers to the current state of the ``StateMachine`` which will be discussed later. -The `responseStatusCode` is the HTTP response status code (which might be dropped in further updates). - -To set the parameter remotely from a HTTP client, one needs to use the PUT HTTP method. -HTTP defines certain 'verbs' like GET, POST, PUT, DELETE etc. Each verb can be used to mean a certain action at a specified URL (or resource representation), -a list of which can be found on Mozilla documentation `here `_ . If you wish to -retrieve the value of a parameter or run the getter method, you need to make a GET request at the specified URL. The browser search bar always executes a GET request which -explains the JSON response obtained above with the value of the `serial_number`. If you need to change the value of a parameter (`serial_number` here) or run its setter method, -you need to make a PUT request at the same URL. The http request method can be modified on a parameter by specifying a tuple at ``http_method`` argument, but its not generally necessary. - -Now, we would like to define methods. A `connect` and `disconnect` method may be implemented as follows: - -.. code-block:: python - :emphasize-lines: 1 - - from hololinked.server import RemoteObject, remote_method, post - from seabreeze.spectrometers import Spectrometer - ... - - class OceanOpticsSpectrometer(RemoteObject): - ... - - model = String(default=None, allow_None=True, URL_path='/model', - doc='the model of the connected spectrometer') - - @remote_method(http_method='POST', URL_path='/connect') - def connect(self, trigger_mode = None, integration_time = None): - self.device = Spectrometer.from_serial_number(self.serial_number) - self.model = self.device.model - self.logger.debug(f"opened device with serial number {self.serial_number} with model {self.model}") - - # the above remote_method() can be shortened as - @post('/disconnect') - def disconnect(self): - self.device.close() - - - if __name__ == '__main__': - ... - H.start(block=False) # creates a new process - ... - O.run() - - -Here we define methods connect & disconnect as remote methods, accessible under HTTP request method POST. The full -URL path will be as follows: - -.. list-table:: - - * - format - - `https://{domain name}/{instance name}/{method URL path}` - * - connect() - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/connect` - * - disconnect() - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/disconnect` - -The paths '/connect' and '/disconnect' are called RPC-style end-points (or resource representation). We directly specify a name for the method in the URL, and generally -use the POST HTTP request to execute it. For execution of methods with arbitrary python logic, it is suggested to use POST method. -If there are python methods fetching data (say after some computations), GET request method may be more suitable (in which you can directly access the -from the browser search bar). For `connect` and `disconnect`, since we do not fetch useful data after running the method, we use the POST method. - -Importantly, |module-highlighted| restricts method execution to one method at a time although HTTP Server handle multiple requests at once. -This is due to how remote procedure calls are implemented. Even if you define both `connect` and `disconnect` methods for remote access, -when you execute `connect` method, disconnect cannot be executed even if you try to POST at that URL while `connect` is running & vice-versa. -The request will be queued with a certain timeout (which can also be modified). -The queuing can be overcome only if you execute the method by threading it with your own logic. - -Now we also define further options for the spectrometer, starting with the integration time. - -.. code-block:: python - :emphasize-lines: 15,20 - - from hololinked.param import String, Number - ... - - class OceanOpticsSpectrometer(RemoteObject): - ... - - model = String(default=None, allow_None=True, URL_path='/model', - doc='the model of the connected spectrometer') - - integration_time_millisec = Number(default=1000, bounds=(0.001, None), crop_to_bounds=True, - URL_path='/integration-time', - doc="integration time of measurement in milliseconds") - ... - - @integration_time_millisec.setter - def _set_integration_time_ms(self, value): - self.device.integration_time_micros(int(value*1000)) - self._integration_time_ms = int(value) - - @integration_time_millisec.getter - def _get_integration_time_ms(self): - try: - return self._integration_time_ms - except: - return self.parameters["integration_time_millisec"].default - - # the above can be shortened as - @post('/disconnect') - def disconnect(self): - self.device.close() - - - if __name__ == '__main__': - ... - -For this parameter, we will use a custom getter and setter method because `seabreeze` does not seem to memorize the value or return it from the device. -The setter method directly applies the value on the device and stores in an internal variable when successful. While retrieving the value, the stored value -or default value is returned. Next, trigger modes: - - -.. code-block:: python - :emphasize-lines: 19 - - from hololinked.param import String, Number, Selector - ... - - class OceanOpticsSpectrometer(RemoteObject): - - def _set_trigger_mode(self, value): - self.device.trigger_mode(value) - self._trigger_mode = value - - def _get_trigger_mode(self): - try: - return self._trigger_mode - except: - return self.parameters["trigger_mode"].default - - ... - - trigger_mode = Selector(objects=[0,1,2,3,4], default=1, URL_path='/trigger-mode', - fget=_get_trigger_mode, fset=_set_trigger_mode, - doc="""0 = normal/free running, 1 = Software trigger, 2 = Ext. Trigger Level, - 3 = Ext. Trigger Synchro/ Shutter mode, 4 = Ext. Trigger Edge""") - # Option 2 for specifying getter and setter methods - ... - - - - # the above can be shortened as - @post('/disconnect') - def disconnect(self): - self.device.close() - - - if __name__ == '__main__': - ... - -The ``Selector`` parameter allows one of several values to be chosen. The manufacturer allows only when the options specified -in the ``doc`` argument, therefore we use the ``objects=[0,1,2,3,4]`` to restrict the values to one of the specified. -The ``objects`` list can accept any python data type. Again, we will use a custom getter-setter method to directly apply -the setting on the device. Further, the value is passed to the setter method is always verified internally prior to invoking it. -The same verification also applies to `integration_time`, where the value will verified to be a float or int and be cropped to the -bounds specified in ``crop_to_bounds`` argument before calling the setter method. - -After we connect to the instrument, lets say, we would like to have some information about the supported wavelengths and -pixels: - -.. code-block:: python - - from hololinked.param import String, Number, Selector, ClassSelector, Integer - ... - - class OceanOpticsSpectrometer(RemoteObject): - - ... - - wavelengths = ClassSelector(default=None, allow_None=True, class_=(numpy.ndarray, list), - URL_path='/wavelengths', doc="Wavelength bins of the spectrometer device") - - pixel_count = Integer(default=None, allow_None=True, URL_path='/pixel-count', - doc="Number of points in wavelength" ) - - @remote_method(http_method='POST', URL_path='/connect') - def connect(self, trigger_mode = None, integration_time = None): - """ - connect to the spectrometer and retrieve information about it - """ - self.device = Spectrometer.from_serial_number(self.serial_number) - self.wavelengths = self.device.wavelengths() - self.model = self.device.model - self.pixel_count = self.device.pixels - - ... - - if __name__ == '__main__': - ... - - -To make some basic tests on the object, let us complete it by defining measurement methods -`start_acquisition` and `stop_acquisition`. To collect the data, we also need a data container. -We define a data container called `Intensity` - -.. code-block:: python - :caption: data.py - - import datetime - import numpy - from dataclasses import dataclass, asdict - - - @dataclass - class Intensity: - value : numpy.ndarray - timestamp : str - - def json(self): - return { - 'value' : self.value.tolist(), - 'timestamp' : self.timestamp - } - - @property - def not_completely_black(self): - if any(self.value[i] > 0 for i in range(len(self.value))): - return True - return False - - -Within the OceanOpticsSpectrometer class, - -.. code-block:: python - - ... - from .data import Intensity - - class OceanOpticsSpectrometer(RemoteObject): - - ... - last_intensity = ClassSelector(default=None, allow_None=True, class_=Intensity, - URL_path='/intensity', doc="last measurement intensity (in arbitrary units)") - - ... - -i.e. since intensity will be stored within an instance of `Intensity`, we need to use a ``ClassSelector`` parameter -which accepts values as an instance of classes specified under ``class_`` argument. Let us define the measurement loop: - -.. code-block:: python - - def measure(self, max_count = None): - self._running = True - self.logger.info(f'starting continuous acquisition loop with trigger mode {self.trigger_mode} & integration time {self.integration_time}') - loop = 0 - while self._running: - if max_count is not None and loop >= max_count: - break - try: - # Following is a blocking command - self.spec.intensities - _current_intensity = self.device.intensities( - correct_dark_counts=True, - correct_nonlinearity=True - ) - timestamp = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S') - self.logger.debug(f'measurement taken at {timestamp} - measurement count {loop+1}') - if self._running: - # To stop the acquisition in hardware trigger mode, we set running to False in stop_acquisition() - # and then change the trigger mode for self.spec.intensities to unblock. This exits this - # infintie loop. Therefore, to know, whether self.spec.intensities finished, whether due to trigger - # mode or due to actual completion of measurement, we check again if self._running is True. - if any(_current_intensity [i] > 0 for i in range(len(_current_intensity))): - self.last_intensity = Intensity( - value=_current_intensity, - timestamp=timestamp - ) - self.logger.debug(f'measurement taken at {self.last_intensity.timestamp} - measurement count {loop}') - else: - self.logger.warn('trigger delayed or no trigger or erroneous data - completely black') - loop += 1 - except Exception as ex: - self.logger.error(f'error during acquisition : {str(ex)}') - - self.logger.info("ending continuous acquisition") - - -The measurement method is an infinite loop. Therefore, it will need to be threaded to not block further requests from clients or -allow execution of other remote methods like stopping the measurement. When we start acquisition, we need to be able to stop acquisition -while acquisition is still running and vice versa. - -.. code-block:: python - - import threading - ... - - class OceanOpticsSpectrometer(RemoteObject): - - ... - - @post('/acquisition/start') - def start_acquisition(self): - self.stop_acquisition() # Just a shield - self._acquisition_thread = threading.Thread(target=self.measure) - self._acquisition_thread.start() - - @post('/acquisition/stop') - def stop_acquisition(self): - if self._acquisition_thread is not None: - self.logger.debug(f"stopping acquisition thread with thread-ID {self._acquisition_thread.ident}") - self._running = False # break infinite loop - # Reduce the measurement that will proceed in new trigger mode to 1ms - self.device.integration_time_micros(1000) - # Change Trigger Mode if anything else other than 0, which will cause for the measurement loop to block permanently - self.device.trigger_mode(0) - self._acquisition_thread.join() - self._acquisition_thread = None - # re-apply old values - self.trigger_mode = self.trigger_mode - self.integration_time_millisec = self.integration_time_millisec - - -Now, we need to be able to constrain the execution of methods & setting of parameters using a state machine. When the device is disconnected or running measurements, -it does not make sense to update measurement settings. Or, the connect method can be run only the device is disconnected and vice-versa. For this, -we use the ``StateMachine`` class. - -.. code-block:: python - - from hololinked.server import RemoteObject, remote_method, post, StateMachine - from enum import Enum - ... - - class OceanOpticsSpectrometer(RemoteObject): - - states = Enum('states', 'DISCONNECTED ON MEASURING') - - ... - - @post('/acquisition/stop') - def stop_acquisition(self): - ... - - state_machine = StateMachine( - states=states, - initial_state=states.DISCONNECTED, - DISCONNECTED=[connect], - ON=[disconnect, start_acquisition, integration_time_millisec, trigger_mode], - MEASURING=[stop_acquisition], - ) - -We have three states `ON`, `DISCONNECTED`, `MEASURING` which will be specified as an Enum. We will pass this `states` to the ``StateMachine`` -construtor to denote possible states in the state machine, while specifying the `initial_state` to be `DISCONNECTED`. Next, using the state names as keyword arguments, -a list of methods and parameters whose setter can be executed in that state are specified. When the device is disconnected, we can only connect to the device. -When the device is connected, it will go to `ON` state and allow measurement settings to be changed. During measurement, we are only allowed to stop measurement. -We need to still trigger the state transitions manually: - -.. code-block:: python - :emphasize-lines: 13,19,23,26 - - ... - class OceanOpticsSpectrometer(RemoteObject): - - ... - - states = Enum('states', 'DISCONNECTED ON MEASURING') - - ... - - @remote_method(http_method='POST', URL_path='/connect') - def connect(self, trigger_mode = None, integration_time = None): - self.device = Spectrometer.from_serial_number(self.serial_number) - self.state_machine.current_state = self.states.ON - ... - - @post('/disconnect') - def disconnect(self): - self.device.close() - self.state_machine.current_state = self.states.DISCONNECTED - - def measure(self, max_count = None): - self._running = True - self.state_machine.current_state = self.states.MEASURING - while self._running: - ... - self.state_machine.current_state = self.states.ON - self.logger.info("ending continuous acquisition") - self._running = False - -Finally, the clients need to be informed whenever a measurement has been made. This can be helpful, say, to plot a graph. Instead of -making the clients repeatedly poll for the `intensity` to find out if a new value is available, its more efficient to inform the clients -whenever the measurement has completed without the clients asking. These are generally termed as server-sent-events. To create such an event, -the following recipe can be used - -.. code-block:: python - :emphasize-lines: 9,19 - - from hololinked.server import RemoteObject, remote_method, post, StateMachine, Event - ... - class OceanOpticsSpectrometer(RemoteObject): - ... - - def __init__(self, serial_number : str, **kwargs): - super().__init__(serial_number=serial_number, **kwargs) - ... - self.intensity_measurement_event = Event(name='intensity-measurement-event', URL_path='/intensity/measurement-event') - - def measure(self, max_count = None): - ... - ... - if any(_current_intensity[i] > 0 for i in range(len(_current_intensity))): - self.last_intensity = Intensity( - value=_current_intensity, - timestamp=timestamp - ) - self.intensity_measurement_event.push(self.last_intensity) - ... - ... - -In the ``Intensity`` dataclass, a `json()` method was defined. This method informs the built-in JSON serializer of ``hololinked`` to serialize -data to JSON compliant format whenever necessary. Once the event is pushed, its tunnelled as an HTTP server sent event by the HTTP Server using -the JSON serializer. The event can be accessed at |br| -`https://{domain name}/{instance name}/{event URL path}`, which gives -`https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/intensity/measurement-event` -for the intensity event. - -The browser will already display the event data when you type the URL in the search bar, but it will not be formatted nicely. - -.. warning:: - generally for event streaming, https is necessary - -To update our ``HTTPServer`` to have SSL encryption, we can modify it as follows: - -.. code-block:: python - :caption: executor.py - - from multiprocessing import Process - from hololinked.server import HTTPServer - from device import OceanOpticsSpectrometer - - def start_http_server(): - ssl_context = ssl.SSLContext(protocol = ssl.PROTOCOL_TLS) - ssl_context.load_cert_chain('assets\\security\\certificate.pem', - keyfile = 'assets\\security\\key.pem') - - H = HTTPServer(consumers=['spectrometer/ocean-optics/USB2000-plus'], port=8083, ssl_context=ssl_context, - log_level=logging.DEBUG) - H.start() - - - if __name__ == "__main__": - # You need to create a certificate on your own - P = Process(target=start_http_server) - P.start() - - O = OceanOpticsSpectrometer( - instance_name='spectrometer/ocean-optics/USB2000-plus', - serial_number='USB2+H15897', - log_level=logging.DEBUG, - trigger_mode=0 - ) - O.run() - -The ``SSLContext`` contains a SSL certificate. A professionally recognised SSL certificate may be used, but in this example a self-created -certificate will be used. This certificate is not generally recognised by the browser unless explicit permission is given. Further, the SSLContext -cannot be serialized by python's built-in ``multiprocessing.Process``, so we will fork the process manually and create a ``SSLContext`` in the new process. - -Let us summarize all the HTTP end-points of the parameters, methods and events: - -.. list-table:: - - * - object - - URL path - - HTTP request method - * - connect() - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/connect` - - POST - * - disconnect() - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/disconnect` - - POST - * - serial_number - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/serial-number` - - GET (read), PUT (write) - * - model - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/model` - - GET (read) - * - integration_time_millisec - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/integration-time` - - GET (read), PUT(write) - * - trigger_mode - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/trigger-mode` - - GET (read), PUT(write) - * - pixel_count - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/pixel-count` - - GET (read) - * - wavelengths - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/wavelengths` - - GET (read) - * - intensity - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/intensity` - - GET (read) - * - intensity_measurement_event - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/intensity/measurement-event` - - GET (SSE) - * - start_acquisition - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/acquisition/start` - - POST - * - stop_acquisition - - `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus/acquisition/stop` - - POST - - - -.. warning:: - This example does not strictly comply to API design practices - -.. note:: - - In order to see all your defined methods, parameters & events, you could also use ``hololinked-portal``. - There is a `RemoteObject client` feature which can load the HTTP exposed resources of your RemoteObject. - In the search bar, you can type `https://localhost:8083/spectrometer/ocean-optics/USB2000-plus` - To build a GUI in ReactJS, `this article `_ can be a guide. - -One can already test this & continue to next article for improvements. \ No newline at end of file diff --git a/doc/source/howto/clients.rst b/doc/source/howto/clients.rst deleted file mode 100644 index 20b969e..0000000 --- a/doc/source/howto/clients.rst +++ /dev/null @@ -1,112 +0,0 @@ -.. |br| raw:: html - -
- -Connecting to Things with Clients -================================= - -When using a HTTP server, it is possible to use any HTTP client including web browser provided clients like ``XMLHttpRequest`` -and ``EventSource`` object. This is the intention of providing HTTP support. However, additional possibilities exist which are noteworthy: - -Using ``hololinked.client`` ---------------------------- - -To use ZMQ transport methods to connect to the server instead of HTTP, one can use an object proxy available in -``hololinked.client``. For certain applications, for example, oscilloscope traces consisting of millions of data points, -or, camera images or video streaming with raw pixel density & no compression, the ZMQ transport may significantly speed -up the data transfer rate. Especially one may use a different serializer like MessagePack instead of JSON. -JSON is the default, and currently the only supported serializer for HTTP applications and is still meant to be used -to interface such data-heavy devices with HTTP clients. Nevertheless, ZMQ transport is simultaneously possible along -with using HTTP. -|br| -To use a ZMQ client from a different python process other than the ``Thing``'s running process, one needs to start the -``Thing`` server using TCP or IPC (inter-process communication) transport methods and **not** with ``run_with_http_server()`` -method (which allows only INPROC/intra-process communication). Use the ``run()`` method instead and specify the desired -ZMQ transport layers: - -.. literalinclude:: code/rpc.py - :language: python - :linenos: - :lines: 1-2, 9-13, 74-81 - -Then, import the ``ObjectProxy`` and specify the ZMQ transport method and ``instance_name`` to connect to the server and -the object it serves: - -.. literalinclude:: code/rpc_client.py - :language: python - :linenos: - :lines: 1-9 - -The exposed properties, actions and events become available on the client. One can use get-set on properties, function -calls on actions and subscribe to events with a callback which is executed once an event arrives: - -.. literalinclude:: code/rpc_client.py - :language: python - :linenos: - :lines: 23-27 - -One would be making such remote procedure calls from a PyQt graphical interface, custom acquisition scripts or -measurement scan routines which may be running in the same or a different computer on the network. Use TCP ZMQ transport -to be accessible from network clients. - -.. literalinclude:: code/rpc.py - :language: python - :linenos: - :lines: 75, 84-87 - -Irrespective of client's request origin, whether TCP, IPC or INPROC, requests are always queued before executing. To repeat: - -* TCP - raw TCP transport facilitated by ZMQ (therefore, without details of HTTP) for clients on the network. You might - need to open your firewall. Currently, neither encryption nor user authorization security is provided, use HTTP if you - need these features. -* IPC - interprocess communication for accessing by other process within the same computer. One can use this instead of - using TCP with firewall or single computer applications. -* INPROC - only clients from the same python process can access the server. - -If one needs type definitions for the client because the client does not know the server to which it is connected, one -can import the server script ``Thing`` and set it as the type of the client as a quick-hack. - -.. literalinclude:: code/rpc_client.py - :language: python - :linenos: - :lines: 15-20 - -Serializer customization is discussed further in :doc:`Serializer How-To `. - -Using ``node-wot`` client -------------------------- - -``node-wot`` is an interoperable Javascript client provided by the `Web of Things Working Group `_. -The purpose of this client is to be able to interact with devices with a web standard compatible JSON specification called -as the "`Thing Description `_", which -allows interoperability irrespective of protocol implementation and application domain. The said JSON specification -describes the device's available properties, actions and events and provides human-readable documentation of the device -within the specification itself, enhancing developer experience. |br| -For example, consider the ``serial_number`` property defined previously, the following JSON schema can describe the property: - -.. literalinclude:: code/node-wot/serial_number.json - :language: JSON - :linenos: - -Similarly, ``connect`` action and ``measurement_event`` event may be described as follows: - -.. literalinclude:: code/node-wot/actions_and_events.json - :language: JSON - :linenos: - -It might be already understandable that from such a JSON specification, it is clear how to interact with the specified property, -action or event. The ``node-wot`` client consumes such a specification to provide these interactions for the developer. -``node-wot`` already has protocol bindings like HTTP, CoAP, Modbus, MQTT etc. which can be used in nodeJS or in web browsers. -Since ``hololinked`` offers the possibility of HTTP bindings for devices, such a JSON Thing description is auto generated by -the class to be able to use by the node-wot client. To use the node-wot client on the browser: - -.. literalinclude:: code/node-wot/intro.js - :language: javascript - :linenos: - -There are few reasons one might consider to use ``node-wot`` compared to traditional HTTP client, first and foremost being -standardisation across different protocols. Irrespective of hardware protocol support, including HTTP bindings from -``hololinked``, one can use the same API. For example, one can directly issue modbus calls to a modbus device while -issuing HTTP calls to ``hololinked`` ``Thing``s. Further, node-wot offers validation of property types, action payloads and -return values, and event data without additional programming effort. - diff --git a/doc/source/howto/code/4.py b/doc/source/howto/code/4.py deleted file mode 100644 index d4778fc..0000000 --- a/doc/source/howto/code/4.py +++ /dev/null @@ -1,57 +0,0 @@ -from hololinked.server import RemoteObject, remote_method, HTTPServer, Event -from hololinked.server.remote_parameters import String, ClassSelector -from seabreeze.spectrometers import Spectrometer -import numpy - - -class OceanOpticsSpectrometer(RemoteObject): - """ - Spectrometer example object - """ - - serial_number = String(default=None, allow_None=True, constant=True, - URL_path="/serial-number", - doc="serial number of the spectrometer") - - model = String(default=None, URL_path='/model', allow_None=True, - doc="model of the connected spectrometer") - - def __init__(self, instance_name, serial_number, connect, **kwargs): - super().__init__(instance_name=instance_name, **kwargs) - self.serial_number = serial_number - if connect and self.serial_number is not None: - self.connect() - self.measurement_event = Event(name='intensity-measurement', - URL_path='/intensity/measurement-event') - - @remote_method(URL_path='/connect', http_method="POST") - def connect(self, trigger_mode = None, integration_time = None): - self.device = Spectrometer.from_serial_number(self.serial_number) - self.model = self.device.model - self.logger.debug(f"opened device with serial number \ - {self.serial_number} with model {self.model}") - if trigger_mode is not None: - self.trigger_mode = trigger_mode - if integration_time is not None: - self.integration_time = integration_time - - intensity = ClassSelector(class_=(numpy.ndarray, list), default=[], - doc="captured intensity", readonly=True, - URL_path='/intensity', fget=lambda self: self._intensity) - - def capture(self): - self._run = True - while self._run: - self._intensity = self.device.intensities( - correct_dark_counts=True, - correct_nonlinearity=True - ) - self.measurement_event.push(self._intensity.tolist()) - - -if __name__ == '__main__': - spectrometer = OceanOpticsSpectrometer(instance_name='spectrometer', - serial_number='USB2+H15897', connect=True) - spectrometer.run( - http_server=HTTPServer(port=8080) - ) diff --git a/doc/source/howto/code/eventloop/import.py b/doc/source/howto/code/eventloop/import.py deleted file mode 100644 index 7054890..0000000 --- a/doc/source/howto/code/eventloop/import.py +++ /dev/null @@ -1,8 +0,0 @@ -from hololinked.client import ObjectProxy -from hololinked.server import EventLoop - -eventloop_proxy = ObjectProxy(instance_name='eventloop', protocol="TCP", - socket_address="tcp://192.168.0.10:60000") #type: EventLoop -obj_id = eventloop_proxy.import_remote_object(file_name=r"D:\path\to\file\IDSCamera", - object_name="UEyeCamera") -eventloop_proxy.instantiate(obj_id, instance_name='camera', device_id=3) \ No newline at end of file diff --git a/doc/source/howto/code/eventloop/list_of_devices.py b/doc/source/howto/code/eventloop/list_of_devices.py deleted file mode 100644 index c761cb1..0000000 --- a/doc/source/howto/code/eventloop/list_of_devices.py +++ /dev/null @@ -1,23 +0,0 @@ -from oceanoptics_spectrometer import OceanOpticsSpectrometer -from IDSCamera import UEyeCamera -from hololinked.server import EventLoop, HTTPServer -from multiprocessing import Process - - -def start_http_server(): - server = HTTPServer(remote_objects=['spectrometer', 'eventloop', 'camera']) - server.start() - -if __name__ == '__main__()': - - Process(target=start_http_server).start() - - spectrometer = OceanOpticsSpectrometer(instance_name='spectrometer', - serial_number='USB2+H15897') - - camera = UEyeCamera(instance_name='camera', camera_id=3) - - EventLoop( - [spectrometer, camera], - instance_name='eventloop', - ).run() \ No newline at end of file diff --git a/doc/source/howto/code/eventloop/run_eq.py b/doc/source/howto/code/eventloop/run_eq.py deleted file mode 100644 index 9c59938..0000000 --- a/doc/source/howto/code/eventloop/run_eq.py +++ /dev/null @@ -1,19 +0,0 @@ -from oceanoptics_spectrometer import OceanOpticsSpectrometer -from IDSCamera import UEyeCamera -from hololinked.server import EventLoop, HTTPServer -from multiprocessing import Process - - -def start_http_server(): - server = HTTPServer(remote_objects=['spectrometer', 'eventloop']) - server.start() - -if __name__ == '__main__()': - - Process(target=start_http_server).start() - - EventLoop( - OceanOpticsSpectrometer(instance_name='spectrometer', - serial_number='USB2+H15897'), - instance_name='eventloop', - ).run() \ No newline at end of file diff --git a/doc/source/howto/code/eventloop/threaded.py b/doc/source/howto/code/eventloop/threaded.py deleted file mode 100644 index 14a5038..0000000 --- a/doc/source/howto/code/eventloop/threaded.py +++ /dev/null @@ -1,24 +0,0 @@ -from oceanoptics_spectrometer import OceanOpticsSpectrometer -from IDSCamera import UEyeCamera -from hololinked.server import EventLoop, HTTPServer -from multiprocessing import Process - - -def start_http_server(): - server = HTTPServer(remote_objects=['spectrometer', 'eventloop', 'camera']) - server.start() - -if __name__ == '__main__()': - - Process(target=start_http_server).start() - - spectrometer = OceanOpticsSpectrometer(instance_name='spectrometer', - serial_number='USB2+H15897') - - camera = UEyeCamera(instance_name='camera', camera_id=3) - - EventLoop( - [spectrometer, camera], - instance_name='eventloop', - threaded=True - ).run() \ No newline at end of file diff --git a/doc/source/howto/code/node-wot/actions_and_events.json b/doc/source/howto/code/node-wot/actions_and_events.json deleted file mode 100644 index 2cead39..0000000 --- a/doc/source/howto/code/node-wot/actions_and_events.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "actions" : { - "connect": { - "title": "connect", - "description" : "connect to the spectrometer specified by serial number", - "forms": [{ - "href": "https://example.com/spectrometer/connect", - "op": "invokeaction", - "htv:methodName": "POST", - "contentType": "application/json" - }] - } - }, - "events" : { - "measurement_event": { - "forms": [{ - "href": "https://example.com/spectrometer/intensity/measurement-event", - "op": "subscribeevent", - "htv:methodName": "GET", - "contentType": "text/event-stream" - }] - } - - } -} diff --git a/doc/source/howto/code/node-wot/intro.js b/doc/source/howto/code/node-wot/intro.js deleted file mode 100644 index c897449..0000000 --- a/doc/source/howto/code/node-wot/intro.js +++ /dev/null @@ -1,30 +0,0 @@ -import 'wot-bundle.min.js'; - -servient = new Wot.Core.Servient(); // auto-imported by wot-bundle.min.js -servient.addClientFactory(new Wot.Http.HttpsClientFactory({ allowSelfSigned : true })) - -servient.start().then(async (WoT) => { - console.debug("WoT servient started") - let td = await WoT.requestThingDescription( - "https://example.com/spectrometer/resources/wot-td") - // replace with your own PC hostname - - spectrometer = await WoT.consume(td); - console.info("consumed thing description from spectrometer") - - // read and write property - await spectrometer.writeProperty("serial_number", { "value" : "USB2+H15897"}) - console.log(await (await spectrometer.readProperty("serial number")).value()) - - //call actions - await spectrometer.invokeAction("connect") - - spectrometer.subscribeEvent("measurement_event", async(data) => { - const value = await data.value() - console.log("event : ", value) - }).then((subscription) => { - console.debug("subscribed to intensity measurement event") - }) - - await spectrometer.invokeAction("capture") -}) diff --git a/doc/source/howto/code/node-wot/serial_number.json b/doc/source/howto/code/node-wot/serial_number.json deleted file mode 100644 index b092d13..0000000 --- a/doc/source/howto/code/node-wot/serial_number.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "serial_number" : { - "title": "serial_number", - "description": "serial number of the spectrometer to connect/or connected", - "constant": false, - "readOnly": false, - "type": "string", - "forms": [ - { - "href": "https://example.com/spectrometer/serial-number", - "op": "readproperty", - "htv:methodName": "GET", - "contentType": "application/json" - }, - { - "href": "https://example.com/spectrometer/serial-number", - "op": "writeproperty", - "htv:methodName": "PUT", - "contentType": "application/json" - } - ] - } -} diff --git a/doc/source/howto/code/properties/common_arg_1.py b/doc/source/howto/code/properties/common_arg_1.py deleted file mode 100644 index 2cf1c8d..0000000 --- a/doc/source/howto/code/properties/common_arg_1.py +++ /dev/null @@ -1,37 +0,0 @@ -from hololinked.server import RemoteObject -from hololinked.server.remote_parameters import String, Number, TypedList - - -class OceanOpticsSpectrometer(RemoteObject): - """ - Spectrometer example object - """ - - serial_number = String(default="USB2+H15897", allow_None=False, readonly=True, - doc="serial number of the spectrometer (string)") # type: str - - integration_time_millisec = Number(default=1000, bounds=(0.001, None), - crop_to_bounds=True, allow_None=False, - doc="integration time of measurement in milliseconds") - - model = String(default=None, allow_None=True, constant=True, - doc="model of the connected spectrometer") - - custom_background_intensity = TypedList(item_type=(float, int), default=None, - allow_None=True, - doc="user provided background substraction intensity") - - def __init__(self, instance_name, serial_number, integration_time) -> None: - super().__init__(instance_name=instance_name) - # allow_None - self.custom_background_intensity = None # OK - self.custom_background_intensity = [] # OK - self.custom_background_intensity = None # OK - # following raises TypeError because allow_None = False - self.integration_time_millisec = None - # readonly - following raises ValueError because readonly = True - self.serial_number = serial_number - # constant - self.model = None # OK - constant accepts None - self.model = 'USB2000+' # OK - can be set once - self.model = None # raises TypeError \ No newline at end of file diff --git a/doc/source/howto/code/properties/common_arg_2.py b/doc/source/howto/code/properties/common_arg_2.py deleted file mode 100644 index 1aa3736..0000000 --- a/doc/source/howto/code/properties/common_arg_2.py +++ /dev/null @@ -1,33 +0,0 @@ -from hololinked.server import RemoteObject, RemoteParameter -from enum import IntEnum - - -class ErrorCodes(IntEnum): - IS_NO_SUCCESS = -1 - IS_SUCCESS = 0 - IS_INVALID_CAMERA_HANDLE = 1 - IS_CANT_OPEN_DEVICE = 3 - IS_CANT_CLOSE_DEVICE = 4 - - @classmethod - def json(self): - # code to code name - opposite of enum definition - return {value.value : name for name, value in vars(self).items() if isinstance( - value, self)} - - -class IDSCamera(RemoteObject): - """ - Spectrometer example object - """ - error_codes = RemoteParameter(readonly=True, default=ErrorCodes.json(), - class_member=True, - doc="error codes raised by IDS library") - - def __init__(self, instance_name : str): - super().__init__(instance_name=instance_name) - print("error codes", IDSCamera.error_codes) # prints error codes - - -if __name__ == '__main__': - IDSCamera(instance_name='test') \ No newline at end of file diff --git a/doc/source/howto/code/properties/typed.py b/doc/source/howto/code/properties/typed.py deleted file mode 100644 index f1294c5..0000000 --- a/doc/source/howto/code/properties/typed.py +++ /dev/null @@ -1,33 +0,0 @@ -from hololinked.server import RemoteObject -from hololinked.server.remote_parameters import String, Number - - -class OceanOpticsSpectrometer(RemoteObject): - """ - Spectrometer example object - """ - - serial_number = String(default="USB2+H15897", allow_None=False, readonly=True, - doc="serial number of the spectrometer (string)") # type: str - - integration_time_millisec = Number(default=1000, bounds=(0.001, None), - crop_to_bounds=True, - doc="integration time of measurement in milliseconds") - - def __init__(self, instance_name, serial_number, integration_time) -> None: - super().__init__(instance_name=instance_name) - self.serial_number = serial_number # raises ValueError because readonly=True - - @integration_time_millisec.setter - def set_integration_time(self, value): - # value is already validated as a float or int - # & cropped to specified bounds when this setter invoked - self.device.integration_time_micros(int(value*1000)) - self._integration_time_ms = int(value) - - @integration_time_millisec.getter - def get_integration_time(self): - try: - return self._integration_time_ms - except: - return self.parameters["integration_time_millisec"].default \ No newline at end of file diff --git a/doc/source/howto/code/properties/untyped.py b/doc/source/howto/code/properties/untyped.py deleted file mode 100644 index d3c450b..0000000 --- a/doc/source/howto/code/properties/untyped.py +++ /dev/null @@ -1,32 +0,0 @@ -from hololinked.server import RemoteObject, RemoteParameter - - -class TestObject(RemoteObject): - - my_untyped_serializable_attribute = RemoteParameter(default=5, - allow_None=False, doc="this parameter can hold any value") - - my_custom_typed_serializable_attribute = RemoteParameter(default=[2, "foo"], - allow_None=False, doc="this parameter can hold any value") - - @my_custom_typed_serializable_attribute.getter - def get_param(self): - try: - return self._foo - except AttributeError: - return self.parameters.descriptors["my_custom_typed_serializable_attribute"].default - - @my_custom_typed_serializable_attribute.setter - def set_param(self, value): - if isinstance(value, (list, tuple)) and len(value) < 100: - for index, val in enumerate(value): - if not isinstance(val, (str, int, type(None))): - raise ValueError(f"Value at position {index} not acceptable member" - " type of my_custom_typed_serializable_attribute", - f" but type {type(val)}") - self._foo = value - else: - raise TypeError(f"Given type is not list or tuple", - f" for my_custom_typed_serializable_attribute but type {type(value)}") - - diff --git a/doc/source/howto/code/rpc.py b/doc/source/howto/code/rpc.py deleted file mode 100644 index 7dfb2ce..0000000 --- a/doc/source/howto/code/rpc.py +++ /dev/null @@ -1,87 +0,0 @@ -import logging, os, ssl -from multiprocessing import Process -import threading -from hololinked.server import HTTPServer, Thing, Property, action, Event -from hololinked.server.constants import HTTP_METHODS -from hololinked.server.properties import String, List -from seabreeze.spectrometers import Spectrometer - - -class OceanOpticsSpectrometer(Thing): - """ - Spectrometer example object - """ - - serial_number = String(default=None, allow_None=True, constant=True, - URL_path='/serial-number', - doc="serial number of the spectrometer") # type: str - - def __init__(self, instance_name, serial_number, autoconnect, **kwargs): - super().__init__(instance_name=instance_name, **kwargs) - self.serial_number = serial_number - if autoconnect and self.serial_number is not None: - self.connect() - self.measurement_event = Event(name='intensity-measurement') - self._acquisition_thread = None - - @action(URL_path='/connect') - def connect(self, trigger_mode = None, integration_time = None): - self.device = Spectrometer.from_serial_number(self.serial_number) - if trigger_mode: - self.device.trigger_mode(trigger_mode) - if integration_time: - self.device.integration_time_micros(integration_time) - - intensity = List(default=None, allow_None=True, doc="captured intensity", - readonly=True, fget=lambda self: self._intensity.tolist()) - - def capture(self): - self._run = True - while self._run: - self._intensity = self.device.intensities( - correct_dark_counts=True, - correct_nonlinearity=True - ) - self.measurement_event.push(self._intensity.tolist()) - - @action(URL_path='/acquisition/start', http_method=HTTP_METHODS.POST) - def start_acquisition(self): - if self._acquisition_thread is None: - self._acquisition_thread = threading.Thread(target=self.capture) - self._acquisition_thread.start() - - @action(URL_path='/acquisition/stop', http_method=HTTP_METHODS.POST) - def stop_acquisition(self): - if self._acquisition_thread is not None: - self.logger.debug(f"stopping acquisition thread with thread-ID {self._acquisition_thread.ident}") - self._run = False # break infinite loop - # Reduce the measurement that will proceed in new trigger mode to 1ms - self._acquisition_thread.join() - self._acquisition_thread = None - - -def start_https_server(): - ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS) - ssl_context.load_cert_chain(f'assets{os.sep}security{os.sep}certificate.pem', - keyfile = f'assets{os.sep}security{os.sep}key.pem') - # You need to create a certificate on your own or use without one - # for quick-start but events will not be supported by browsers - # if there is no SSL - - HTTPServer(['spectrometer'], port=8083, ssl_context=ssl_context, - log_level=logging.DEBUG).listen() - - -if __name__ == "__main__": - - Process(target=start_https_server).start() - - spectrometer = OceanOpticsSpectrometer(instance_name='spectrometer', - serial_number=None, autoconnect=False) - spectrometer.run(zmq_protocols="IPC") - - # example code, but will never reach here unless exit() is called by the client - spectrometer = OceanOpticsSpectrometer(instance_name='spectrometer', - serializer='msgpack', serial_number=None, autoconnect=False) - spectrometer.run(zmq_protocols=["TCP", "IPC"], - tcp_socket_address="tcp://0.0.0.0:6539") \ No newline at end of file diff --git a/doc/source/howto/code/rpc_client.py b/doc/source/howto/code/rpc_client.py deleted file mode 100644 index 0f002cd..0000000 --- a/doc/source/howto/code/rpc_client.py +++ /dev/null @@ -1,28 +0,0 @@ -from hololinked.client import ObjectProxy - -spectrometer_proxy = ObjectProxy(instance_name='spectrometer', - serializer='msgpack', protocol='IPC') -# setting and getting property -spectrometer_proxy.serial_number = 'USB2+H15897' -print(spectrometer_proxy.serial_number) -# calling actions -spectrometer_proxy.connect() -spectrometer_proxy.capture() - -exit(0) - -# TCP and event example -from hololinked.client import ObjectProxy -from oceanoptics_spectrometer import OceanOpticsSpectrometer - -spectrometer_proxy = ObjectProxy(instance_name='spectrometer', protocol='TCP', - serializer='msgpack', socket_address="tcp://192.168.0.100:6539") # type: OceanOpticsSpectrometer -spectrometer_proxy.serial_number = 'USB2+H15897' -spectrometer_proxy.connect() # provides type definitions corresponding to server - -def event_cb(event_data): - print(event_data) - -spectrometer_proxy.subscribe_event(name='intensity-measurement', callbacks=event_cb) -# name can be the value for name given to the event in the server side or the -# python attribute where the Event was assigned. diff --git a/doc/source/howto/code/thing_inheritance.py b/doc/source/howto/code/thing_inheritance.py deleted file mode 100644 index b7370c4..0000000 --- a/doc/source/howto/code/thing_inheritance.py +++ /dev/null @@ -1,16 +0,0 @@ -from hololinked.server import Thing, Property, action, Event - -class Spectrometer(Thing): - """ - add class doc here - """ - def __init__(self, instance_name, serial_number, autoconnect, **kwargs): - super().__init__(instance_name=instance_name, **kwargs) - self.serial_number = serial_number - if autoconnect: - self.connect() - - def connect(self): - """implemenet device driver logic to connect to hardware""" - pass - \ No newline at end of file diff --git a/doc/source/howto/code/thing_with_http_server.py b/doc/source/howto/code/thing_with_http_server.py deleted file mode 100644 index f00b17a..0000000 --- a/doc/source/howto/code/thing_with_http_server.py +++ /dev/null @@ -1,96 +0,0 @@ -import threading, logging -from hololinked.server import Thing, Property, action, Event -from hololinked.server.properties import Number, Selector, String, List -from hololinked.server.constants import HTTP_METHODS -from seabreeze.spectrometers import Spectrometer - - -class OceanOpticsSpectrometer(Thing): - """ - Spectrometer example object - """ - - serial_number = String(default=None, allow_None=True, constant=True, - URL_path='/serial-number', - doc="serial number of the spectrometer") # type: str - - def __init__(self, instance_name, serial_number, autoconnect, **kwargs): - super().__init__(instance_name=instance_name, serial_number=serial_number, - **kwargs) - if autoconnect and self.serial_number is not None: - self.connect(trigger_mode=0, integration_time=int(1e6)) # let's say, by default - self._acquisition_thread = None - self.measurement_event = Event(name='intensity-measurement') - - @action(URL_path='/connect') - def connect(self, trigger_mode, integration_time): - self.device = Spectrometer.from_serial_number(self.serial_number) - if trigger_mode: - self.device.trigger_mode(trigger_mode) - if integration_time: - self.device.integration_time_micros(integration_time) - - integration_time = Number(default=1000, bounds=(0.001, None), crop_to_bounds=True, - URL_path='/integration-time', - doc="integration time of measurement in milliseconds") - - @integration_time.setter - def apply_integration_time(self, value : float): - self.device.integration_time_micros(int(value*1000)) - self._integration_time = int(value) - - @integration_time.getter - def get_integration_time(self) -> float: - try: - return self._integration_time - except: - return self.parameters["integration_time"].default - - trigger_mode = Selector(objects=[0, 1, 2, 3, 4], default=0, URL_path='/trigger-mode', - doc="""0 = normal/free running, 1 = Software trigger, 2 = Ext. Trigger Level, - 3 = Ext. Trigger Synchro/ Shutter mode, 4 = Ext. Trigger Edge""") - - @trigger_mode.setter - def apply_trigger_mode(self, value : int): - self.device.trigger_mode(value) - self._trigger_mode = value - - @trigger_mode.getter - def get_trigger_mode(self): - try: - return self._trigger_mode - except: - return self.parameters["trigger_mode"].default - - intensity = List(default=None, allow_None=True, doc="captured intensity", - readonly=True, fget=lambda self: self._intensity.tolist()) - - def capture(self): - self._run = True - while self._run: - self._intensity = self.device.intensities( - correct_dark_counts=False, - correct_nonlinearity=False - ) - self.measurement_event.push(self._intensity.tolist()) - self.logger.debug(f"pushed measurement event") - - @action(URL_path='/acquisition/start', http_method=HTTP_METHODS.POST) - def start_acquisition(self): - if self._acquisition_thread is None: - self._acquisition_thread = threading.Thread(target=self.capture) - self._acquisition_thread.start() - - @action(URL_path='/acquisition/stop', http_method=HTTP_METHODS.POST) - def stop_acquisition(self): - if self._acquisition_thread is not None: - self.logger.debug(f"stopping acquisition thread with thread-ID {self._acquisition_thread.ident}") - self._run = False # break infinite loop - self._acquisition_thread.join() - self._acquisition_thread = None - - -if __name__ == '__main__': - spectrometer = OceanOpticsSpectrometer(instance_name='spectrometer', - serial_number='S14155', autoconnect=True, log_level=logging.DEBUG) - spectrometer.run_with_http_server(port=3569) diff --git a/doc/source/howto/eventloop.rst b/doc/source/howto/eventloop.rst deleted file mode 100644 index cf9ab0f..0000000 --- a/doc/source/howto/eventloop.rst +++ /dev/null @@ -1,35 +0,0 @@ -Customizing Eventloop -===================== - -EventLoop object is a server side object that runs both the ZMQ message listeners & executes the operations -of the ``RemoteObject``. Operations only include parameter read-write & method execution, events are pushed synchronously -wherever they are called. -EventLoop is also a ``RemoteObject`` by itself. A default eventloop is created by ``RemoteObject.run()`` method to -simplify the usage, however, one may benefit from using it directly. - -To start a ``RemoteObject`` using the ``EventLoop``, pass the instantiated object to the ``__init__()``: - -.. literalinclude:: code/eventloop/run_eq.py - :language: python - :linenos: - -Exposing the EventLoop allows to add new ``RemoteObject``'s on the fly whenever necessary. To run multiple objects -in the same eventloop, pass the objects as a list. - -.. literalinclude:: code/eventloop/list_of_devices.py - :language: python - :linenos: - :lines: 7- - -Setting threaded to True calls each RemoteObject in its own thread. - -.. literalinclude:: code/eventloop/threaded.py - :language: python - :linenos: - :lines: 20- - -Use proxies to import a new object from somewhere else: - -.. literalinclude:: code/eventloop/import.py - :language: python - :linenos: \ No newline at end of file diff --git a/doc/source/howto/http_server.rst b/doc/source/howto/http_server.rst deleted file mode 100644 index 5873f11..0000000 --- a/doc/source/howto/http_server.rst +++ /dev/null @@ -1,45 +0,0 @@ -Connect HTTP server to RemoteObject -=================================== - -To also use a HTTP server, one needs to specify URL paths and HTTP request verb for the parameters and methods. - -one needs to start a instance of ``HTTPServer`` before ``run()``. When passed to the ``run()``, -the ``HTTPServer`` will communicate with the ``RemoteObject`` through the fastest means -possible - intra-process communication. - -.. literalinclude:: code/thing_with_http_server.py - :language: python - :linenos: - -The ``HTTPServer`` and ``RemoteObject`` will run in different threads and the python global -interpreter lock will still allow only one thread at a time. - -One can store captured data in parameters & push events to supply clients with the measured -data: - -.. literalinclude:: code/thing_with_http_server.py - :language: python - :linenos: - -When using HTTP server, events will also be tunneled as HTTP server sent events at the specifed URL -path. - -Endpoints available to HTTP server are constructed as follows: - -.. list-table:: - - * - remote resource - - default HTTP request method - - URL construction - * - parameter read - - GET - - `http(s)://{domain name}/{instance name}/{parameter URL path}` - * - parameter write - - PUT - - `http(s)://{domain name}/{instance name}/{parameter URL path}` - * - method execution - - POST - - `http(s)://{domain name}/{instance name}/{method URL path}` - * - Event - - GET - - `http(s)://{domain name}/{instance name}/{event URL path}` diff --git a/doc/source/howto/index.rst b/doc/source/howto/index.rst deleted file mode 100644 index a5d402a..0000000 --- a/doc/source/howto/index.rst +++ /dev/null @@ -1,82 +0,0 @@ -.. |module| replace:: hololinked - -.. |module-highlighted| replace:: ``hololinked`` - -.. toctree:: - :hidden: - :maxdepth: 2 - - Expose Python Classes - clients - - -Expose Python Classes -===================== - -Python objects visible on the network or to other processes are made by subclassing from ``Thing``: - -.. literalinclude:: code/thing_inheritance.py - :language: python - :linenos: - -``instance_name`` is a unique name recognising the instantiated object. It allows multiple -instruments of same type to be connected to the same computer without overlapping the exposed interface and is therefore a -mandatory argument to be supplied to the ``Thing`` parent. When maintained unique within the network, it allows -identification of the hardware itself. Non-experts may use strings composed of -characters, numbers, dashes and forward slashes, which looks like part of a browser URL, but the general definition is -that ``instance_name`` should be a URI compatible string. - -For attributes (like serial number above), if one requires them to be exposed on the network, one should -use "properties" defined in ``hololinked.server.properties`` to "type define" (in a python sense) attributes of the object. - -.. literalinclude:: code/thing_with_http_server.py - :language: python - :linenos: - :lines: 2, 5-19 - -Only properties defined in ``hololinked.server.properties`` or subclass of ``Property`` object (note the captial 'P') -can be exposed to the network, not normal python attributes or python's own ``property``. - -For methods to be exposed on the network, one can use the ``action`` decorator: - -.. literalinclude:: code/thing_with_http_server.py - :language: python - :linenos: - :lines: 2-3, 7-19, 24-31 - -Arbitrary signature is permitted. Arguments are loosely typed and may need to be constrained with a schema, based -on the robustness the developer is expecting in their application. However, a schema is optional and it only matters that -the method signature is matching when requested from a client. - -To start a HTTP server for the ``Thing``, one can call the ``run_with_http_server()`` method after instantiating the -``Thing``. The supplied ``URL_path`` to the actions and properties are used by this HTTP server: - -.. literalinclude:: code/thing_with_http_server.py - :language: python - :linenos: - :lines: 93-96 - - -By default, this starts a server a HTTP server and an INPROC zmq socket (GIL constrained intra-process as far as python is -concerned) for the HTTP server to direct the requests to the ``Thing`` object. All requests are queued by default as the -domain of operation under the hood is remote procedure calls (RPC). - -One can store captured data in properties & push events to supply clients with the measured data: - -.. literalinclude:: code/thing_with_http_server.py - :language: python - :linenos: - :lines: 2-3, 5-19, 64-82 - -Events can be defined as class or instance attributes and will be tunnelled as HTTP server sent events. -Events are to be used to asynchronously push data to clients. - -It can be summarized that the three main building blocks of a network exposed object, or a hardware ``Thing`` are: - -* properties - use them to model settings of instrumentation (both hardware and software-only), - expose general class/instance attributes, captured & computed data -* actions - use them to issue commands to instruments like start and stop acquisition, connect/disconnect etc. -* events - push measured data, create alerts/alarms, inform availability of certain type of data etc. - -Each are separately discussed in depth in their respective sections within the doc found on the section navigation. - diff --git a/doc/source/howto/methods/index.rst b/doc/source/howto/methods/index.rst deleted file mode 100644 index 48a0e75..0000000 --- a/doc/source/howto/methods/index.rst +++ /dev/null @@ -1,15 +0,0 @@ -Actions (or remote methods) -=========================== - -Only methods decorated with ``action()`` are exposed to clients. - -.. literalinclude:: ../code/4.py - :lines: 1-10, 26-36 - -Since python is loosely typed, the server may need to verify the argument types -supplied by the client call. This verification is left to the developer and there -is no elaborate support for this. One may consider using a ``ParameterizedFunction`` for -this or supplying a JSON schema to the argument ``input_schema`` of ``remote_method`` - -To constrain method excecution for certain states of the StateMachine, one can -set the state in the decorator. \ No newline at end of file diff --git a/doc/source/howto/properties/arguments.rst b/doc/source/howto/properties/arguments.rst deleted file mode 100644 index f5e7dcd..0000000 --- a/doc/source/howto/properties/arguments.rst +++ /dev/null @@ -1,88 +0,0 @@ -Common arguments to all parameters -================================== - -``allow_None``, ``constant`` & ``readonly`` -+++++++++++++++++++++++++++++++++++++++++++ - -* if ``allow_None`` is ``True``, parameter supports ``None`` apart from its own type -* ``readonly`` (being ``True``) makes the parameter read-only or execute the getter method -* ``constant`` (being ``True``), again makes the parameter read-only but can be set once if ``allow_None`` is ``True``. - This is useful the set the parameter once at ``__init__()`` but remain constant after that. - -.. literalinclude:: ../code/parameters/common_arg_1.py - :language: python - :linenos: - -``default``, ``class_member``, ``fget``, ``fset`` & ``fdel`` -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - -To provide a getter-setter (& deleter) method is optional. If none given, value is stored inside -the instance's ``__dict__`` under the name `_param_value`. If no such -value was stored originally because a value assignment was never called on the parameter, say during -``__init__``, ``default`` is returned. - -If a setter/deleter is given, getter is mandatory. In this case, ``default`` is also ignored & the getter is -always executed. If default is desirable, one has to return it manually in the getter method by -accessing the parameter descriptor object directly. - -If ``class_member`` is True, the value is set in the class' ``__dict__`` instead of instance's ``__dict__``. -Custom getter-setter-deleter are not compatible with this option currently. ``class_member`` takes precedence over fget-fset-fdel, -which in turn has precedence over ``default``. - -.. literalinclude:: ../code/parameters/common_arg_2.py - :language: python - :linenos: - :lines: 5-29 - -``class_member`` can still be used with a default value if there is no custom fget-fset-fdel. - -``doc`` and ``label`` -++++++++++++++++++++++ - -``doc`` allows clients to fetch a docstring for the parameter. ``label`` can be used to show the parameter -in a GUI for example. hololinked-portal uses these two values in the same fashion. - -``remote`` -++++++++++ - -setting remote to False makes the parameter local, this is still useful to type-restrict python attributes to -provide an interface to other developers using your class, for example, when someone else inherits your ``RemoteObject``. - -``URL_path`` and ``http_method`` -++++++++++++++++++++++++++++++++ - -This setting is applicable only to the ``HTTPServer``. ``URL_path`` makes the parameter available for -getter-setter-deleter methods at the specified URL. The default http request verb/method for getter is GET, -setter is PUT and deleter is DELETE. If one wants to change the setter to POST method instead of PUT, -one can set ``http_method = ("GET", "POST", "DELETE")``. Even without the custom getter-setter -(which generates the above stated internal name for the parameter), one can modify the ``http_method``. -Setting any of the request methods to ``None`` makes the parameter in-accessible for that respective operation. - -``state`` -+++++++++ - -When ``state`` is specifed, the parameter is writeable only when the RemoteObject's StateMachine is in that state (or -in the list of allowed states). This is also currently applicable only when set operations are called by clients. -Local set operations are always executed irrespective of the state machine state. A get operation is always executed as -well even from the clients irrespective of the state. - -``metadata`` -++++++++++++ - -This dictionary allows storing arbitrary metadata in a dictionary. For example, one can store units of the physical -quantity. - -``db_init``, ``db_commit`` & ``db_persist`` -+++++++++++++++++++++++++++++++++++++++++++ - -Parameters can be stored & loaded in a database if necessary when the ``RemoteObject`` is stopped and restarted. - -* ``db_init`` only loads a parameter from database, when the value is changed, its not written back to the database. - For this option, the value has to be pre-created in the database in some other fashion. hololinked-portal can help here. - -* ``db_commit`` only writes the value into the database when an assignment is called. - -* ``db_persist`` both stores and loads the parameter from the database. - -Supported databases are MySQL, Postgres & SQLite currently. Look at database how-to for supply database configuration. - diff --git a/doc/source/howto/properties/extending.rst b/doc/source/howto/properties/extending.rst deleted file mode 100644 index bbffd13..0000000 --- a/doc/source/howto/properties/extending.rst +++ /dev/null @@ -1,9 +0,0 @@ -Extending -========= - -Remote Parameters can also be extended to define custom types based on specific requirement. -Type validation is carried out in a method ``validate_and_adapt()`` of the ``RemoteParameter`` class -or its child. The given value can also be coerced/adapted if necessary. - - - diff --git a/doc/source/howto/properties/index.rst b/doc/source/howto/properties/index.rst deleted file mode 100644 index e8db201..0000000 --- a/doc/source/howto/properties/index.rst +++ /dev/null @@ -1,103 +0,0 @@ -Properties In-Depth -=================== - -Properties expose python attributes to clients & support custom get-set(-delete) functions. -``hololinked`` uses ``param`` under the hood to implement properties. - -.. toctree:: - :hidden: - :maxdepth: 1 - - arguments - extending - -Untyped Property ------------------ - -To make a property take any value, use the base class ``Property`` - -.. literalinclude:: ../code/properties/untyped.py - :language: python - :linenos: - :lines: 1-11 - -The descriptor object (instance of ``Property``) that performs the get-set operations & auto-allocation -of an internal instance variable for the property can be accessed by the instance under -``self.properties.descriptors[""]``. Expectedly, the value of the property must -be serializable to be read by the clients. Read the serializer section for further details & customization. - -Custom Typed ------------- - -To support custom get & set methods so that an internal instance variable is not created automatically, -use the getter & setter decorator or pass a method to the fget & fset arguments of the property: - -.. literalinclude:: ../code/properties/untyped.py - :language: python - :linenos: - :lines: 1-30 - - -Typed Properties ----------------- - -Certain typed properties are already available in ``hololinked.server.properties``, -defined by ``param``. - -.. list-table:: - - * - type - - property class - - options - * - str - - ``String`` - - comply to regex - * - integer - - ``Integer`` - - min & max bounds, inclusive bounds, crop to bounds, multiples - * - float, integer - - ``Number`` - - min & max bounds, inclusive bounds, multiples - * - bool - - ``Boolean`` - - - * - iterables - - ``Iterable`` - - length/bounds, item_type, dtype (allowed type of iterable like list, tuple) - * - tuple - - ``Tuple`` - - same as iterable - * - list - - ``List`` - - same as iterable - * - one of many objects - - ``Selector`` - - allowed list of objects - * - one or more of many objects - - ``TupleSelector``` - - allowed list of objects - * - class, subclass or instance of an object - - ``ClassSelector`` - - instance only or class only - * - path, filename & folder names - - ``Path``, ``Filename``, ``Foldername`` - - - * - datetime - - ``Date`` - - format - * - typed list - - ``TypedList`` - - typed appends, extends - * - typed dictionary - - ``TypedDict``, ``TypedKeyMappingsDict`` - - typed updates, assignments - -As an example: - -.. literalinclude:: ../code/properties/typed.py - :language: python - :linenos: - -When providing a custom setter for typed properties, the value is internally validated before -passing to the setter method. The return value of getter method is never validated and -is left to the programmer's choice. \ No newline at end of file diff --git a/doc/source/howto/remote_object.rst b/doc/source/howto/remote_object.rst deleted file mode 100644 index 69f429d..0000000 --- a/doc/source/howto/remote_object.rst +++ /dev/null @@ -1,14 +0,0 @@ -RemoteObject In-Depth -===================== - -Change Protocols ----------------- - -``hololinked`` uses ZeroMQ under the hood to mediate messages between client and server. -Any ``RemoteObject`` can be constrained to - -* only intra-process communication for single process apps -* inter-process communication for multi-process apps -* network communication using TCP and/or HTTP - -simply by specify the requirement as an argument. \ No newline at end of file diff --git a/doc/source/howto/serializers.rst b/doc/source/howto/serializers.rst deleted file mode 100644 index 0ddfd26..0000000 --- a/doc/source/howto/serializers.rst +++ /dev/null @@ -1,2 +0,0 @@ -Customizing Serializers -======================= \ No newline at end of file diff --git a/doc/source/index.rst b/doc/source/index.rst deleted file mode 100644 index 016757b..0000000 --- a/doc/source/index.rst +++ /dev/null @@ -1,58 +0,0 @@ -.. hololinked documentation master file, created by - sphinx-quickstart on Sat Oct 28 22:19:33 2023. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -.. |module| replace:: hololinked - -.. |module-highlighted| replace:: ``hololinked`` - -.. |base-class-highlighted| replace:: ``Thing`` - -|module| - Pythonic Supervisory Control & Data Acquisition / Internet of Things -=============================================================================== - -|module-highlighted| is (supposed to be) a versatile and pythonic tool for building custom control and data acquisition -software systems. If you have a requirement to capture data from your hardware/instrumentation remotely through your -domain network, control them, show the data in a browser/dashboard, provide a Qt-GUI or run automated scripts, -|module-highlighted| can help. Even if you wish to do data-acquisition/control locally in a single computer, one can still -separate the concerns of GUI & device or integrate with web-browser for a modern interface or use modern web development -based tools. |module-highlighted| is being developed with the following features in mind: - -* being truly pythonic - all code in python & all features of python -* reasonable integration with HTTP to take advantage of modern web practices -* easy to understand & setup -* agnostic to system size & flexibility in topology - -In short - to use it in your home/hobby, in a lab or in a research facility & industry. - -|module-highlighted| is compatible with the `Web of Things `_ recommended pattern for developing -hardware/instrumentation control software. Each device or thing can be controlled systematically when their design in -software is segregated into properties, actions and events. |module-highlighted| is object-oriented, therefore: - -* properties are validated get-set attributes of the class which may be used to model device settings, hold captured/computed data etc. -* actions are methods which issue commands to the device or run arbitrary python logic. -* events can asynchronously communicate/push data to a client, like alarm messages, streaming captured data etc. - -The base class which enables this classification is the ``Thing`` class. Any class that inherits the ``Thing`` class can -instantiate properties, actions and events which become visible to a client in this segragated manner. -Please follow the documentation for examples & tutorials, how-to's and API reference. - -.. note:: - web developers & software engineers, consider reading the :ref:`note ` section - -.. toctree:: - :maxdepth: 1 - :hidden: - :caption: Contents: - - - installation - How Tos - autodoc/index - development_notes - - -:ref:`genindex` - -last build : |today| UTC \ No newline at end of file diff --git a/doc/source/installation.rst b/doc/source/installation.rst deleted file mode 100644 index 073f500..0000000 --- a/doc/source/installation.rst +++ /dev/null @@ -1,53 +0,0 @@ -.. |module-highlighted| replace:: ``hololinked`` - -Installation & Examples -======================= - -.. code:: shell - - pip install hololinked - -One may also clone it from github & install directly (in develop mode). - -.. code:: shell - - git clone https://github.com/VigneshVSV/hololinked.git - -Either install the dependencies in requirements file or one could setup a conda environment from the included ``hololinked.yml`` file - -.. code:: shell - - conda env create -f hololinked.yml - - -.. code:: shell - - conda activate hololinked - pip install -e . - -Also check out: - -.. list-table:: - - * - hololinked-examples - - https://github.com/VigneshVSV/hololinked-examples.git - - repository containing example code discussed in this documentation - * - hololinked-portal - - https://github.com/VigneshVSV/hololinked-portal.git - - GUI to access ``Thing``s and interact with their properties, actions and events. - -To build & host docs locally, in top directory: - -.. code:: shell - - conda activate hololinked - cd doc - make clean - make html - python -m http.server --directory build\html - -To open the docs in the default browser, one can also issue the following instead of starting a python server - -.. code:: shell - - make host-doc \ No newline at end of file diff --git a/doc/source/requirements.txt b/doc/source/requirements.txt deleted file mode 100644 index 4cd43e3..0000000 --- a/doc/source/requirements.txt +++ /dev/null @@ -1,24 +0,0 @@ -sphinx==7.3.7 -sphinx-copybutton==0.5.2 -sphinxcontrib-applehelp==1.0.7 -sphinxcontrib-devhelp==1.0.5 -sphinxcontrib-htmlhelp==2.0.4 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.6 -sphinxcontrib-serializinghtml==1.1.9 -pydata-sphinx-theme==0.15.3 -numpydoc==1.6.0 -sphinx-toolbox==3.5.0 -argon2-cffi==23.1.0 -ConfigParser==6.0.0 -ifaddr==0.2.0 -ipython==8.21.0 -numpy==1.26.4 -pandas==2.2.0 -pyzmq==25.1.0 -serpent==1.41 -setuptools==68.0.0 -SQLAlchemy==2.0.21 -SQLAlchemy_Utils==0.41.1 -tornado==6.3.3 -msgspec==0.18.6 \ No newline at end of file diff --git a/examples b/examples new file mode 160000 index 0000000..52897aa --- /dev/null +++ b/examples @@ -0,0 +1 @@ +Subproject commit 52897aa0bd3e84af3a3fb41eda5c3a2e14cf4024 diff --git a/hololinked.yml b/hololinked.yml index 26df6cb..06d7f4d 100644 --- a/hololinked.yml +++ b/hololinked.yml @@ -1,128 +1,108 @@ name: hololinked channels: - - anaconda - conda-forge - defaults dependencies: - - accessible-pygments=0.0.4=pyhd8ed1ab_0 - - alabaster=0.7.13=pyhd8ed1ab_0 + - alabaster=0.7.16=py311haa95532_0 - argon2-cffi=23.1.0=pyhd8ed1ab_0 - - argon2-cffi-bindings=21.2.0=py311h2bbff1b_0 - - arrow=1.3.0=pyhd8ed1ab_0 - - babel=2.13.1=pyhd8ed1ab_0 - - bcrypt=3.2.0=py311h2bbff1b_1 - - beautifulsoup4=4.12.2=pyha770c72_0 - - brotli-python=1.0.9=py311hd77b12b_7 - - bzip2=1.0.8=he774522_0 - - ca-certificates=2023.08.22=haa95532_0 - - certifi=2023.11.17=py311haa95532_0 - - cffi=1.15.1=py311h2bbff1b_3 - - charset-normalizer=3.3.2=pyhd8ed1ab_0 - - colorama=0.4.6=pyhd8ed1ab_0 - - colour=0.1.5=py_0 - - cryptography=41.0.3=py311h89fc84f_0 - - docopt=0.6.2=py_1 - - docutils=0.20.1=py311h1ea47a8_2 - - furl=2.1.3=pyhd8ed1ab_0 - - greenlet=2.0.1=py311hd77b12b_0 - - idna=3.4=pyhd8ed1ab_0 + - argon2-cffi-bindings=21.2.0=py311ha68e1ae_4 + - attrs=23.2.0=pyh71513ae_0 + - babel=2.11.0=py311haa95532_0 + - brotli-python=1.0.9=py311hd77b12b_8 + - bzip2=1.0.8=h2bbff1b_6 + - ca-certificates=2024.6.2=h56e8100_0 + - certifi=2024.6.2=pyhd8ed1ab_0 + - cffi=1.16.0=py311ha68e1ae_0 + - charset-normalizer=2.0.4=pyhd3eb1b0_0 + - colorama=0.4.6=py311haa95532_0 + - docutils=0.18.1=py311haa95532_3 + - greenlet=3.0.3=py311h12c1d0e_0 + - idna=3.7=py311haa95532_0 - ifaddr=0.2.0=pyhd8ed1ab_0 - - imagesize=1.4.1=pyhd8ed1ab_0 - - importlib-metadata=6.8.0=pyha770c72_0 - - infinity=1.5=pyhd8ed1ab_0 - - intervals=0.9.2=pyhd8ed1ab_0 - - jinja2=3.1.2=pyhd8ed1ab_1 - - libffi=3.4.4=hd77b12b_0 - - libpq=12.15=h906ac69_0 + - imagesize=1.4.1=py311haa95532_0 + - importlib_resources=6.4.0=pyhd8ed1ab_0 + - jinja2=3.1.4=py311haa95532_0 + - jsonschema=4.22.0=pyhd8ed1ab_0 + - jsonschema-specifications=2023.12.1=pyhd8ed1ab_0 + - krb5=1.21.2=heb0366b_0 + - libexpat=2.6.2=h63175ca_0 + - libffi=3.4.4=hd77b12b_1 - libsodium=1.0.18=h8d14728_1 - - markupsafe=2.1.1=py311h2bbff1b_0 - - openssl=3.0.12=h2bbff1b_0 - - orderedmultidict=1.0.1=py_0 - - packaging=23.2=pyhd8ed1ab_0 - - passlib=1.7.4=pyh9f0ad1d_0 - - pendulum=2.1.2=pyhd8ed1ab_1 - - phonenumbers=8.13.23=pyhd8ed1ab_0 - - pip=23.3=py311haa95532_0 - - pipreqs=0.4.13=pyhd8ed1ab_0 - - psycopg2=2.9.3=py311h2bbff1b_1 - - pycparser=2.21=pyhd8ed1ab_0 - - pydata-sphinx-theme=0.14.3=pyhd8ed1ab_0 - - pygments=2.16.1=pyhd8ed1ab_0 - - pysocks=1.7.1=pyh0701188_6 - - python=3.11.5=he1021f5_0 - - python-dateutil=2.8.2=pyhd8ed1ab_0 - - python_abi=3.11=2_cp311 - - pytz=2023.3.post1=pyhd8ed1ab_0 - - pytzdata=2020.1=pyh9f0ad1d_0 - - pyyaml=6.0.1=py311h2bbff1b_0 - - pyzmq=25.1.0=py311hd77b12b_0 - - requests=2.31.0=pyhd8ed1ab_0 - - serpent=1.41=pyhd8ed1ab_0 - - setuptools=68.0.0=py311haa95532_0 - - six=1.16.0=pyh6c4a22f_0 - - snowballstemmer=2.2.0=pyhd8ed1ab_0 - - soupsieve=2.5=pyhd8ed1ab_1 - - sphinx=7.2.6=pyhd8ed1ab_0 + - libsqlite=3.46.0=h2466b09_0 + - libzlib=1.3.1=h2466b09_1 + - markupsafe=2.1.3=py311h2bbff1b_0 + - msgspec=0.18.6=py311ha68e1ae_0 + - numpydoc=1.7.0=pyhd8ed1ab_1 + - openssl=3.3.1=h2466b09_0 + - packaging=23.2=py311haa95532_0 + - pip=24.0=py311haa95532_0 + - pkgutil-resolve-name=1.3.10=pyhd8ed1ab_1 + - pycparser=2.22=pyhd8ed1ab_0 + - pygments=2.15.1=py311haa95532_1 + - pysocks=1.7.1=py311haa95532_0 + - python=3.11.9=h631f459_0_cpython + - python_abi=3.11=4_cp311 + - pytz=2024.1=py311haa95532_0 + - pyzmq=26.0.3=py311h484c95c_0 + - referencing=0.35.1=pyhd8ed1ab_0 + - requests=2.32.2=py311haa95532_0 + - rpds-py=0.18.1=py311h533ab2d_0 + - setuptools=69.5.1=py311haa95532_0 + - snowballstemmer=2.2.0=pyhd3eb1b0_0 + - sphinx=7.3.7=py311h827c3e9_0 - sphinx-copybutton=0.5.2=pyhd8ed1ab_0 - - sphinxcontrib-applehelp=1.0.7=pyhd8ed1ab_0 - - sphinxcontrib-devhelp=1.0.5=pyhd8ed1ab_0 - - sphinxcontrib-htmlhelp=2.0.4=pyhd8ed1ab_0 - - sphinxcontrib-jsmath=1.0.1=pyhd8ed1ab_0 - - sphinxcontrib-qthelp=1.0.6=pyhd8ed1ab_0 - - sphinxcontrib-serializinghtml=1.1.9=pyhd8ed1ab_0 - - sqlalchemy=2.0.21=py311h2bbff1b_0 - - sqlalchemy-utils=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-arrow=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-babel=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-base=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-color=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-encrypted=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-intervals=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-password=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-pendulum=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-phone=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-timezone=0.41.1=pyhd8ed1ab_0 - - sqlalchemy-utils-url=0.41.1=pyhd8ed1ab_0 - - sqlite=3.41.2=h2bbff1b_0 - - tk=8.6.12=h2bbff1b_0 - - tornado=6.3.3=py311h2bbff1b_0 - - types-python-dateutil=2.8.19.14=pyhd8ed1ab_0 - - typing-extensions=4.7.1=py311haa95532_0 - - typing_extensions=4.7.1=py311haa95532_0 - - tzdata=2023c=h04d1e81_0 - - urllib3=2.0.7=pyhd8ed1ab_0 - - vc=14.2=h21ff451_1 - - vs2015_runtime=14.27.29016=h5e58377_2 - - wheel=0.41.2=py311haa95532_0 - - win_inet_pton=1.1.0=pyhd8ed1ab_6 - - xz=5.4.2=h8cc25b3_0 - - yaml=0.2.5=he774522_0 - - yarg=0.1.9=py_1 - - zeromq=4.3.4=h0e60522_1 - - zipp=3.17.0=pyhd8ed1ab_0 - - zlib=1.2.13=h8cc25b3_0 + - sphinxcontrib-applehelp=1.0.2=pyhd3eb1b0_0 + - sphinxcontrib-devhelp=1.0.2=pyhd3eb1b0_0 + - sphinxcontrib-htmlhelp=2.0.0=pyhd3eb1b0_0 + - sphinxcontrib-jsmath=1.0.1=pyhd3eb1b0_0 + - sphinxcontrib-qthelp=1.0.3=pyhd3eb1b0_0 + - sphinxcontrib-serializinghtml=1.1.10=py311haa95532_0 + - sqlalchemy=2.0.30=py311he736701_0 + - sqlite=3.45.3=h2bbff1b_0 + - tabulate=0.9.0=pyhd8ed1ab_1 + - tk=8.6.13=h5226925_1 + - tomli=2.0.1=pyhd8ed1ab_0 + - tornado=6.4.1=py311he736701_0 + - typing-extensions=4.12.2=hd8ed1ab_0 + - typing_extensions=4.12.2=pyha770c72_0 + - tzdata=2024a=h04d1e81_0 + - ucrt=10.0.22621.0=h57928b3_0 + - urllib3=2.2.1=py311haa95532_0 + - vc=14.2=h2eaa2aa_1 + - vc14_runtime=14.40.33810=ha82c5b3_20 + - vs2015_runtime=14.40.33810=h3bf8584_20 + - wheel=0.43.0=py311haa95532_0 + - win_inet_pton=1.1.0=py311haa95532_0 + - xz=5.4.6=h8cc25b3_1 + - zeromq=4.3.5=he1f189c_4 + - zipp=3.19.2=pyhd8ed1ab_0 + - zlib=1.3.1=h2466b09_1 - pip: + - accessible-pygments==0.0.5 - apeye==1.4.1 - - apeye-core==1.1.4 + - apeye-core==1.1.5 - autodocsumm==0.2.12 - - cachecontrol==0.13.1 - - cssutils==2.9.0 + - beautifulsoup4==4.12.3 + - cachecontrol==0.14.0 + - cssutils==2.11.1 - dict2css==0.3.0.post1 - - domdf-python-tools==3.8.0.post2 - - filelock==3.13.1 + - domdf-python-tools==3.8.1 + - filelock==3.15.1 - html5lib==1.1 - - msgpack==1.0.7 - - msgspec==0.18.6 + - more-itertools==10.3.0 + - msgpack==1.0.8 - natsort==8.4.0 - - numpydoc==1.6.0 - - platformdirs==4.1.0 - - ruamel-yaml==0.18.5 + - platformdirs==4.2.2 + - pydata-sphinx-theme==0.15.3 + - ruamel-yaml==0.18.6 - ruamel-yaml-clib==0.2.8 - - sphinx-autodoc-typehints==1.25.3 + - six==1.16.0 + - soupsieve==2.5 + - sphinx-autodoc-typehints==2.1.1 - sphinx-jinja2-compat==0.2.0.post1 - sphinx-prompt==1.8.0 - sphinx-tabs==3.4.5 - sphinx-toolbox==3.5.0 - - tabulate==0.9.0 + - sqlalchemy-utils==0.41.2 - webencodings==0.5.1 prefix: C:\Users\vvign\.conda\envs\hololinked diff --git a/hololinked/__init__.py b/hololinked/__init__.py index b3f4756..3ced358 100644 --- a/hololinked/__init__.py +++ b/hololinked/__init__.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.2.1" diff --git a/hololinked/client/proxy.py b/hololinked/client/proxy.py index 7e5af58..7f09f22 100644 --- a/hololinked/client/proxy.py +++ b/hololinked/client/proxy.py @@ -3,18 +3,19 @@ import typing import logging import uuid -import zmq + +from ..server.config import global_config from ..server.constants import JSON, CommonRPC, ServerMessage, ResourceTypes, ZMQ_PROTOCOLS from ..server.serializers import BaseSerializer -from ..server.data_classes import RPCResource, ServerSentEvent +from ..server.dataklasses import ZMQResource, ServerSentEvent from ..server.zmq_message_brokers import AsyncZMQClient, SyncZMQClient, EventConsumer, PROXY - +from ..server.schema_validators import BaseSchemaValidator class ObjectProxy: """ - Procedural client for ``RemoteObject``. Once connected to a server, parameters, methods and events are + Procedural client for ``Thing``/``RemoteObject``. Once connected to a server, properties, methods and events are dynamically populated. Any of the ZMQ protocols of the server is supported. Parameters @@ -22,13 +23,13 @@ class ObjectProxy: instance_name: str instance name of the server to connect. invokation_timeout: float, int - timeout to schedule a method call or parameter read/write in server. execution time wait is controlled by + timeout to schedule a method call or property read/write in server. execution time wait is controlled by ``execution_timeout``. When invokation timeout expires, the method is not executed. execution_timeout: float, int - timeout to return without a reply after scheduling a method call or parameter read/write. This timer starts + timeout to return without a reply after scheduling a method call or property read/write. This timer starts ticking only after the method has started to execute. Returning a call before end of execution can lead to change of state in the server. - load_remote_object: bool, default True + load_thing: bool, default True when True, remote object is located and its resources are loaded. Otherwise, only the client is initialised. protocol: str ZMQ protocol used to connect to server. Unlike the server, only one can be specified. @@ -37,8 +38,10 @@ class ObjectProxy: whether to use both synchronous and asynchronous clients. serializer: BaseSerializer use a custom serializer, must be same as the serializer supplied to the server. + schema_validator: BaseSchemaValidator + use a schema validator, must be same as the schema validator supplied to the server. allow_foreign_attributes: bool, default False - allows local attributes for proxy apart from parameters fetched from the server. + allows local attributes for proxy apart from properties fetched from the server. logger: logging.Logger logger instance log_level: int @@ -51,11 +54,12 @@ class ObjectProxy: '__annotations__', '_zmq_client', '_async_zmq_client', '_allow_foreign_attributes', 'identity', 'instance_name', 'logger', 'execution_timeout', 'invokation_timeout', - '_execution_timeout', '_invokation_timeout', '_events', '_noblock_messages' + '_execution_timeout', '_invokation_timeout', '_events', '_noblock_messages', + '_schema_validator' ]) def __init__(self, instance_name : str, protocol : str = ZMQ_PROTOCOLS.IPC, invokation_timeout : float = 5, - load_remote_object = True, **kwargs) -> None: + load_thing = True, **kwargs) -> None: self._allow_foreign_attributes = kwargs.get('allow_foreign_attributes', False) self.instance_name = instance_name self.invokation_timeout = invokation_timeout @@ -63,34 +67,35 @@ def __init__(self, instance_name : str, protocol : str = ZMQ_PROTOCOLS.IPC, invo self.identity = f"{instance_name}|{uuid.uuid4()}" self.logger = kwargs.pop('logger', logging.Logger(self.identity, level=kwargs.get('log_level', logging.INFO))) self._noblock_messages = dict() + self._schema_validator = kwargs.get('schema_validator', None) # compose ZMQ client in Proxy client so that all sending and receiving is # done by the ZMQ client and not by the Proxy client directly. Proxy client only # bothers mainly about __setattr__ and _getattr__ self._async_zmq_client = None self._zmq_client = SyncZMQClient(instance_name, self.identity, client_type=PROXY, protocol=protocol, - rpc_serializer=kwargs.get('serializer', None), handshake=load_remote_object, + zmq_serializer=kwargs.get('serializer', None), handshake=load_thing, logger=self.logger, **kwargs) if kwargs.get("async_mixin", False): self._async_zmq_client = AsyncZMQClient(instance_name, self.identity + '|async', client_type=PROXY, protocol=protocol, - rpc_serializer=kwargs.get('serializer', None), handshake=load_remote_object, + zmq_serializer=kwargs.get('serializer', None), handshake=load_thing, logger=self.logger, **kwargs) - if load_remote_object: - self.load_remote_object() + if load_thing: + self.load_thing() def __getattribute__(self, __name: str) -> typing.Any: obj = super().__getattribute__(__name) - if isinstance(obj, _RemoteParameter): + if isinstance(obj, _Property): return obj.get() return obj def __setattr__(self, __name : str, __value : typing.Any) -> None: if (__name in ObjectProxy._own_attrs or (__name not in self.__dict__ and isinstance(__value, __allowed_attribute_types__)) or self._allow_foreign_attributes): - # allowed attribute types are _RemoteParameter and _RemoteMethod defined after this class + # allowed attribute types are _Property and _RemoteMethod defined after this class return super(ObjectProxy, self).__setattr__(__name, __value) elif __name in self.__dict__: obj = self.__dict__[__name] - if isinstance(obj, _RemoteParameter): + if isinstance(obj, _Property): obj.set(value=__value) return raise AttributeError(f"Cannot set attribute {__name} again to ObjectProxy for {self.instance_name}.") @@ -138,7 +143,7 @@ def set_invokation_timeout(self, value : typing.Union[float, int]) -> None: self._invokation_timeout = value invokation_timeout = property(fget=get_invokation_timeout, fset=set_invokation_timeout, - doc="Timeout in seconds on server side for invoking a method or read/write parameter. \ + doc="Timeout in seconds on server side for invoking a method or read/write property. \ Defaults to 5 seconds and network times not considered." ) @@ -153,7 +158,7 @@ def set_execution_timeout(self, value : typing.Union[float, int]) -> None: self._execution_timeout = value execution_timeout = property(fget=get_execution_timeout, fset=set_execution_timeout, - doc="Timeout in seconds on server side for execution of method or read/write parameter." + + doc="Timeout in seconds on server side for execution of method or read/write property." + "Starts ticking after invokation timeout completes." + "Defaults to None (i.e. waits indefinitely until return) and network times not considered." ) @@ -236,16 +241,16 @@ async def async_invoke(self, method : str, *args, **kwargs) -> typing.Any: return await method.async_call(*args, **kwargs) - def get_parameter(self, name : str, noblock : bool = False) -> typing.Any: + def get_property(self, name : str, noblock : bool = False) -> typing.Any: """ - get parameter specified by name on server. + get property specified by name on server. Parameters ---------- name: str - name of the parameter + name of the property noblock: bool, default False - request the parameter get but collect the reply/value later using a reply id + request the property get but collect the reply/value later using a reply id Raises ------ @@ -254,33 +259,33 @@ def get_parameter(self, name : str, noblock : bool = False) -> typing.Any: Exception: server raised exception are propagated """ - parameter = self.__dict__.get(name, None) # type: _RemoteParameter - if not isinstance(parameter, _RemoteParameter): - raise AttributeError(f"No remote parameter named {parameter}") + prop = self.__dict__.get(name, None) # type: _Property + if not isinstance(prop, _Property): + raise AttributeError(f"No property named {prop}") if noblock: - msg_id = parameter.noblock_get() - self._noblock_messages[msg_id] = parameter + msg_id = prop.noblock_get() + self._noblock_messages[msg_id] = prop return msg_id else: - return parameter.get() + return prop.get() - def set_parameter(self, name : str, value : typing.Any, oneway : bool = False, + def set_property(self, name : str, value : typing.Any, oneway : bool = False, noblock : bool = False) -> None: """ - set parameter specified by name on server with specified value. + set property specified by name on server with specified value. Parameters ---------- name: str - name of the parameter + name of the property value: Any - value of parameter to be set + value of property to be set oneway: bool, default False - only send an instruction to set the parameter but do not fetch the reply. + only send an instruction to set the property but do not fetch the reply. (irrespective of whether set was successful or not) noblock: bool, default False - request the set parameter but collect the reply later using a reply id + request the set property but collect the reply later using a reply id Raises ------ @@ -289,27 +294,27 @@ def set_parameter(self, name : str, value : typing.Any, oneway : bool = False, Exception: server raised exception are propagated """ - parameter = self.__dict__.get(name, None) # type: _RemoteParameter - if not isinstance(parameter, _RemoteParameter): - raise AttributeError(f"No remote parameter named {parameter}") + prop = self.__dict__.get(name, None) # type: _Property + if not isinstance(prop, _Property): + raise AttributeError(f"No property named {prop}") if oneway: - parameter.oneway_set(value) + prop.oneway_set(value) elif noblock: - msg_id = parameter.noblock_set(value) - self._noblock_messages[msg_id] = parameter + msg_id = prop.noblock_set(value) + self._noblock_messages[msg_id] = prop return msg_id else: - parameter.set(value) + prop.set(value) - async def async_get_parameter(self, name : str) -> None: + async def async_get_property(self, name : str) -> None: """ - async(io) get parameter specified by name on server. + async(io) get property specified by name on server. Parameters ---------- name: Any - name of the parameter to fetch + name of the property to fetch Raises ------ @@ -318,23 +323,23 @@ async def async_get_parameter(self, name : str) -> None: Exception: server raised exception are propagated """ - parameter = self.__dict__.get(name, None) # type: _RemoteParameter - if not isinstance(parameter, _RemoteParameter): - raise AttributeError(f"No remote parameter named {parameter}") - return await parameter.async_get() + prop = self.__dict__.get(name, None) # type: _Property + if not isinstance(prop, _Property): + raise AttributeError(f"No property named {prop}") + return await prop.async_get() - async def async_set_parameter(self, name : str, value : typing.Any) -> None: + async def async_set_property(self, name : str, value : typing.Any) -> None: """ - async(io) set parameter specified by name on server with specified value. + async(io) set property specified by name on server with specified value. noblock and oneway not supported for async calls. Parameters ---------- name: str - name of the parameter + name of the property value: Any - value of parameter to be set + value of property to be set Raises ------ @@ -343,20 +348,20 @@ async def async_set_parameter(self, name : str, value : typing.Any) -> None: Exception: server raised exception are propagated """ - parameter = self.__dict__.get(name, None) # type: _RemoteParameter - if not isinstance(parameter, _RemoteParameter): - raise AttributeError(f"No remote parameter named {parameter}") - await parameter.async_set(value) + prop = self.__dict__.get(name, None) # type: _Property + if not isinstance(prop, _Property): + raise AttributeError(f"No property named {prop}") + await prop.async_set(value) - def get_parameters(self, names : typing.List[str], noblock : bool = False) -> typing.Any: + def get_properties(self, names : typing.List[str], noblock : bool = False) -> typing.Any: """ - get parameters specified by list of names. + get properties specified by list of names. Parameters ---------- names: List[str] - names of parameters to be fetched + names of properties to be fetched noblock: bool, default False request the fetch but collect the reply later using a reply id @@ -365,7 +370,7 @@ def get_parameters(self, names : typing.List[str], noblock : bool = False) -> ty Dict[str, Any]: dictionary with names as keys and values corresponding to those keys """ - method = getattr(self, '_get_parameters', None) # type: _RemoteMethod + method = getattr(self, '_get_properties', None) # type: _RemoteMethod if not method: raise RuntimeError("Client did not load server resources correctly. Report issue at github.") if noblock: @@ -376,20 +381,20 @@ def get_parameters(self, names : typing.List[str], noblock : bool = False) -> ty return method(names=names) - def set_parameters(self, values : typing.Dict[str, typing.Any], oneway : bool = False, - noblock : bool = False) -> None: + def set_properties(self, oneway : bool = False, noblock : bool = False, + **properties : typing.Dict[str, typing.Any]) -> None: """ - set parameters whose name is specified by keys of a dictionary + set properties whose name is specified by keys of a dictionary Parameters ---------- - values: Dict[str, Any] - name and value of parameters to be set oneway: bool, default False - only send an instruction to set the parameter but do not fetch the reply. + only send an instruction to set the property but do not fetch the reply. (irrespective of whether set was successful or not) noblock: bool, default False - request the set parameter but collect the reply later using a reply id + request the set property but collect the reply later using a reply id + **properties: Dict[str, Any] + name and value of properties to be set Raises ------ @@ -398,49 +403,49 @@ def set_parameters(self, values : typing.Dict[str, typing.Any], oneway : bool = Exception: server raised exception are propagated """ - if not isinstance(values, dict): - raise ValueError("set_parameters values must be dictionary with parameter names as key") - method = getattr(self, '_set_parameters', None) # type: _RemoteMethod + if len(properties) == 0: + raise ValueError("no properties given to set_properties") + method = getattr(self, '_set_properties', None) # type: _RemoteMethod if not method: raise RuntimeError("Client did not load server resources correctly. Report issue at github.") if oneway: - method.oneway(values=values) + method.oneway(**properties) elif noblock: - msg_id = method.noblock(values=values) + msg_id = method.noblock(**properties) self._noblock_messages[msg_id] = method return msg_id else: - return method(values=values) + return method(**properties) - async def async_get_parameters(self, names) -> None: + async def async_get_properties(self, names) -> None: """ - async(io) get parameters specified by list of names. no block gets are not supported for asyncio. + async(io) get properties specified by list of names. no block gets are not supported for asyncio. Parameters ---------- names: List[str] - names of parameters to be fetched + names of properties to be fetched Returns ------- Dict[str, Any]: - dictionary with parameter names as keys and values corresponding to those keys + dictionary with property names as keys and values corresponding to those keys """ - method = getattr(self, '_get_parameters', None) # type: _RemoteMethod + method = getattr(self, '_get_properties', None) # type: _RemoteMethod if not method: raise RuntimeError("Client did not load server resources correctly. Report issue at github.") return await method.async_call(names=names) - async def async_set_parameters(self, **parameters) -> None: + async def async_set_properties(self, **properties) -> None: """ - async(io) set parameters whose name is specified by keys of a dictionary + async(io) set properties whose name is specified by keys of a dictionary Parameters ---------- values: Dict[str, Any] - name and value of parameters to be set + name and value of properties to be set Raises ------ @@ -449,10 +454,12 @@ async def async_set_parameters(self, **parameters) -> None: Exception: server raised exception are propagated """ - method = getattr(self, '_set_parameters', None) # type: _RemoteMethod + if len(properties) == 0: + raise ValueError("no properties given to set_properties") + method = getattr(self, '_set_properties', None) # type: _RemoteMethod if not method: raise RuntimeError("Client did not load server resources correctly. Report issue at github.") - await method.async_call(**parameters) + await method.async_call(**properties) def subscribe_event(self, name : str, callbacks : typing.Union[typing.List[typing.Callable], typing.Callable], @@ -508,12 +515,33 @@ def unsubscribe_event(self, name : str): raise AttributeError(f"No event named {name}") event.unsubscribe() + + def read_reply(self, message_id : bytes, timeout : typing.Optional[float] = 5000) -> typing.Any: + """ + read reply of no block calls of an action or a property read/write. + """ + obj = self._noblock_messages.get(message_id, None) + if not obj: + raise ValueError('given message id not a one way call or invalid.') + reply = self._zmq_client._reply_cache.get(message_id, None) + if not reply: + reply = self._zmq_client.recv_reply(message_id=message_id, timeout=timeout, + raise_client_side_exception=True) + if not reply: + raise ReplyNotArrivedError(f"could not fetch reply within timeout for message id '{message_id}'") + if isinstance(obj, _RemoteMethod): + obj._last_return_value = reply + return obj.last_return_value # note the missing underscore + elif isinstance(obj, _Property): + obj._last_value = reply + return obj.last_read_value + - def load_remote_object(self): + def load_thing(self): """ - Get exposed resources from server (methods, parameters, events) and remember them as attributes of the proxy. + Get exposed resources from server (methods, properties, events) and remember them as attributes of the proxy. """ - fetch = _RemoteMethod(self._zmq_client, CommonRPC.rpc_resource_read(instance_name=self.instance_name), + fetch = _RemoteMethod(self._zmq_client, CommonRPC.zmq_resource_read(instance_name=self.instance_name), invokation_timeout=self._invokation_timeout) # type: _RemoteMethod reply = fetch() # type: typing.Dict[str, typing.Dict[str, typing.Any]] @@ -523,43 +551,28 @@ def load_remote_object(self): if data["what"] == ResourceTypes.EVENT: data = ServerSentEvent(**data) else: - data = RPCResource(**data) + data = ZMQResource(**data) except Exception as ex: ex.add_note("Did you correctly configure your serializer? " + "This exception occurs when given serializer does not work the same way as server serializer") raise ex from None - elif not isinstance(data, (RPCResource, ServerSentEvent)): - raise RuntimeError("Logic error - deserialized info about server not instance of hololinked.server.data_classes.RPCResource") - if data.what == ResourceTypes.CALLABLE: + elif not isinstance(data, (ZMQResource, ServerSentEvent)): + raise RuntimeError("Logic error - deserialized info about server not instance of hololinked.server.data_classes.ZMQResource") + if data.what == ResourceTypes.ACTION: _add_method(self, _RemoteMethod(self._zmq_client, data.instruction, self.invokation_timeout, - self.execution_timeout, data.argument_schema, self._async_zmq_client), data) - elif data.what == ResourceTypes.PARAMETER: - _add_parameter(self, _RemoteParameter(self._zmq_client, data.instruction, self.invokation_timeout, + self.execution_timeout, data.argument_schema, self._async_zmq_client, self._schema_validator), data) + elif data.what == ResourceTypes.PROPERTY: + _add_property(self, _Property(self._zmq_client, data.instruction, self.invokation_timeout, self.execution_timeout, self._async_zmq_client), data) elif data.what == ResourceTypes.EVENT: assert isinstance(data, ServerSentEvent) event = _Event(self._zmq_client, data.name, data.obj_name, data.unique_identifier, data.socket_address, - serializer=self._zmq_client.rpc_serializer, logger=self.logger) + serializer=self._zmq_client.zmq_serializer, logger=self.logger) _add_event(self, event, data) self.__dict__[data.name] = event - def read_reply(self, message_id : bytes, timeout : typing.Optional[float] = 5000) -> typing.Any: - obj = self._noblock_messages.get(message_id, None) - if not obj: - raise ValueError('given message id not a one way call or invalid.') - reply = self._zmq_client._reply_cache.get(message_id, None) - if not reply: - reply = self._zmq_client.recv_reply(message_id=message_id, timeout=timeout, - raise_client_side_exception=True) - if not reply: - raise ReplyNotArrivedError(f"could not fetch reply within timeout for message id '{message_id}'") - if isinstance(obj, _RemoteMethod): - obj._last_return_value = reply - return obj.last_return_value # note the missing underscore - elif isinstance(obj, _RemoteParameter): - obj._last_value = reply - return obj.last_read_value + # SM = Server Message @@ -573,13 +586,14 @@ def read_reply(self, message_id : bytes, timeout : typing.Optional[float] = 5000 class _RemoteMethod: __slots__ = ['_zmq_client', '_async_zmq_client', '_instruction', '_invokation_timeout', '_execution_timeout', - '_schema', '_last_return_value', '__name__', '__qualname__', '__doc__'] + '_schema', '_schema_validator', '_last_return_value', '__name__', '__qualname__', '__doc__'] # method call abstraction # Dont add doc otherwise __doc__ in slots will conflict with class variable def __init__(self, sync_client : SyncZMQClient, instruction : str, invokation_timeout : typing.Optional[float] = 5, execution_timeout : typing.Optional[float] = None, argument_schema : typing.Optional[JSON] = None, - async_client : typing.Optional[AsyncZMQClient] = None) -> None: + async_client : typing.Optional[AsyncZMQClient] = None, + schema_validator : typing.Optional[typing.Type[BaseSchemaValidator]] = None) -> None: """ Parameters ---------- @@ -596,6 +610,7 @@ def __init__(self, sync_client : SyncZMQClient, instruction : str, invokation_ti self._invokation_timeout = invokation_timeout self._execution_timeout = execution_timeout self._schema = argument_schema + self._schema_validator = schema_validator(self._schema) if schema_validator and argument_schema and global_config.validate_schema_on_client else None @property # i.e. cannot have setter def last_return_value(self): @@ -616,6 +631,8 @@ def __call__(self, *args, **kwargs) -> typing.Any: """ if len(args) > 0: kwargs["__args__"] = args + elif self._schema_validator: + self._schema_validator.validate(kwargs) self._last_return_value = self._zmq_client.execute(instruction=self._instruction, arguments=kwargs, invokation_timeout=self._invokation_timeout, execution_timeout=self._execution_timeout, raise_client_side_exception=True, argument_schema=self._schema) @@ -628,6 +645,8 @@ def oneway(self, *args, **kwargs) -> None: """ if len(args) > 0: kwargs["__args__"] = args + elif self._schema_validator: + self._schema_validator.validate(kwargs) self._zmq_client.send_instruction(instruction=self._instruction, arguments=kwargs, invokation_timeout=self._invokation_timeout, execution_timeout=None, context=dict(oneway=True), argument_schema=self._schema) @@ -635,6 +654,8 @@ def oneway(self, *args, **kwargs) -> None: def noblock(self, *args, **kwargs) -> None: if len(args) > 0: kwargs["__args__"] = args + elif self._schema_validator: + self._schema_validator.validate(kwargs) return self._zmq_client.send_instruction(instruction=self._instruction, arguments=kwargs, invokation_timeout=self._invokation_timeout, execution_timeout=self._execution_timeout, argument_schema=self._schema) @@ -647,17 +668,20 @@ async def async_call(self, *args, **kwargs): raise RuntimeError("async calls not possible as async_mixin was not set at __init__()") if len(args) > 0: kwargs["__args__"] = args + elif self._schema_validator: + self._schema_validator.validate(kwargs) self._last_return_value = await self._async_zmq_client.async_execute(instruction=self._instruction, - arguments=kwargs, invokation_timeout=self._invokation_timeout, raise_client_side_exception=True, + arguments=kwargs, invokation_timeout=self._invokation_timeout, + raise_client_side_exception=True, argument_schema=self._schema) return self.last_return_value # note the missing underscore -class _RemoteParameter: +class _Property: __slots__ = ['_zmq_client', '_async_zmq_client', '_read_instruction', '_write_instruction', '_invokation_timeout', '_execution_timeout', '_last_value', '__name__', '__doc__'] - # parameter get set abstraction + # property get set abstraction # Dont add doc otherwise __doc__ in slots will conflict with class variable def __init__(self, client : SyncZMQClient, instruction : str, invokation_timeout : typing.Optional[float] = 5, @@ -681,7 +705,7 @@ def last_read_value(self) -> typing.Any: @property def last_zmq_message(self) -> typing.List: """ - cache of last message received for this parameter + cache of last message received for this property """ return self._last_value @@ -745,6 +769,7 @@ def __init__(self, client : SyncZMQClient, name : str, obj_name : str, unique_id self._callbacks = None self._serializer = serializer self._logger = logger + self._subscribed = False def add_callbacks(self, callbacks : typing.Union[typing.List[typing.Callable], typing.Callable]) -> None: if not self._callbacks: @@ -758,7 +783,7 @@ def subscribe(self, callbacks : typing.Union[typing.List[typing.Callable], typin thread_callbacks : bool = False): self._event_consumer = EventConsumer(self._unique_identifier, self._socket_address, f"{self._name}|RPCEvent|{uuid.uuid4()}", b'PROXY', - rpc_serializer=self._serializer, logger=self._logger) + zmq_serializer=self._serializer, logger=self._logger) self.add_callbacks(callbacks) self._subscribed = True self._thread_callbacks = thread_callbacks @@ -795,10 +820,10 @@ def unsubscribe(self, join_thread : bool = True): -__allowed_attribute_types__ = (_RemoteParameter, _RemoteMethod, _Event) +__allowed_attribute_types__ = (_Property, _RemoteMethod, _Event) __WRAPPER_ASSIGNMENTS__ = ('__name__', '__qualname__', '__doc__') -def _add_method(client_obj : ObjectProxy, method : _RemoteMethod, func_info : RPCResource) -> None: +def _add_method(client_obj : ObjectProxy, method : _RemoteMethod, func_info : ZMQResource) -> None: if not func_info.top_owner: return raise RuntimeError("logic error") @@ -810,14 +835,14 @@ def _add_method(client_obj : ObjectProxy, method : _RemoteMethod, func_info : RP setattr(method, dunder, info) client_obj.__setattr__(func_info.obj_name, method) -def _add_parameter(client_obj : ObjectProxy, parameter : _RemoteParameter, parameter_info : RPCResource) -> None: - if not parameter_info.top_owner: +def _add_property(client_obj : ObjectProxy, property : _Property, property_info : ZMQResource) -> None: + if not property_info.top_owner: return raise RuntimeError("logic error") for attr in ['__doc__', '__name__']: # just to imitate _add_method logic - setattr(parameter, attr, parameter_info.get_dunder_attr(attr)) - client_obj.__setattr__(parameter_info.obj_name, parameter) + setattr(property, attr, property_info.get_dunder_attr(attr)) + client_obj.__setattr__(property_info.obj_name, property) def _add_event(client_obj : ObjectProxy, event : _Event, event_info : ServerSentEvent) -> None: setattr(client_obj, event_info.obj_name, event) diff --git a/hololinked/param/parameterized.py b/hololinked/param/parameterized.py index e8f83e0..068aea2 100644 --- a/hololinked/param/parameterized.py +++ b/hololinked/param/parameterized.py @@ -202,7 +202,7 @@ class Foo(Bar): # overhead, Parameters are implemented using __slots__ (see # http://www.python.org/doc/2.4/ref/slots.html). - __slots__ = ['default', 'doc', 'constant', 'readonly', 'allow_None', + __slots__ = ['default', 'doc', 'constant', 'readonly', 'allow_None', 'label', 'per_instance_descriptor', 'deepcopy_default', 'class_member', 'precedence', 'owner', 'name', '_internal_name', 'watchers', 'fget', 'fset', 'fdel', '_disable_post_slot_set'] @@ -212,8 +212,8 @@ class Foo(Bar): # class is created, owner, name, and _internal_name are # set. - def __init__(self, default : typing.Any, *, doc : typing.Optional[str] = None, - constant : bool = False, readonly : bool = False, allow_None : bool = False, + def __init__(self, default : typing.Any, *, doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, per_instance_descriptor : bool = False, deepcopy_default : bool = False, class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: # pylint: disable-msg=R0913 @@ -293,6 +293,7 @@ class hierarchy (see ParameterizedMetaclass). self.constant = constant # readonly is also constant however constants can be set once self.readonly = readonly self.allow_None = constant or allow_None + self.label = label self.per_instance_descriptor = per_instance_descriptor self.deepcopy_default = deepcopy_default self.class_member = class_member @@ -365,6 +366,8 @@ def __get__(self, obj : typing.Union['Parameterized', typing.Any], instance's value, if one has been set - otherwise produce the class's value (default). """ + if self.class_member: + return objtype.__dict__.get(self._internal_name, self.default) if obj is None: return self if self.fget is not None: @@ -398,7 +401,7 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin item in a list). """ if self.readonly: - raise_TypeError("Read-only parameter cannot be set/modified.", self) + raise_ValueError("Read-only parameter cannot be set/modified.", self) value = self.validate_and_adapt(value) @@ -409,7 +412,7 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin old = None if (obj.__dict__.get(self._internal_name, NotImplemented) != NotImplemented) or self.default is not None: # Dont even entertain any type of setting, even if its the same value - raise_TypeError("Constant parameter cannot be modified.", self) + raise_ValueError("Constant parameter cannot be modified.", self) else: old = obj.__dict__.get(self._internal_name, self.default) @@ -447,6 +450,11 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin event_dispatcher.call_watcher(watcher, event) if not event_dispatcher.state.BATCH_WATCH: event_dispatcher.batch_call_watchers() + + def __delete__(self, obj : typing.Union['Parameterized', typing.Any]) -> None: + if self.fdel is not None: + return self.fdel(obj) + raise NotImplementedError("Parameter deletion not implemented.") def validate_and_adapt(self, value : typing.Any) -> typing.Any: """ @@ -462,8 +470,7 @@ def __getstate__(self): """ All Parameters have slots, not a dict, so we have to support pickle and deepcopy ourselves. - """ - + """ state = {} for slot in self.__slots__ + self.__parent_slots__: state[slot] = getattr(self, slot) @@ -1664,7 +1671,7 @@ def _deep_copy_param_descriptor(self, param_obj : Parameter): self._instance_params[param_obj.name] = param_obj_copy - def add_parameter(self, param_name: str, param_obj: Parameter) -> None: + def add(self, param_name : str, param_obj : Parameter) -> None: setattr(self.owner_inst, param_name, param_obj) if param_obj.deepcopy_default: self._deep_copy_param_default(param_obj) @@ -2051,6 +2058,7 @@ def __reduce__(self): def __new__(cls, *args, **params): # Create and __call__() an instance of this class. inst = super().__new__(cls) + inst.__init__(**params) return inst.__call__(*args, **params) diff --git a/hololinked/param/parameters.py b/hololinked/param/parameters.py index aa5005a..44da3c2 100644 --- a/hololinked/param/parameters.py +++ b/hololinked/param/parameters.py @@ -883,9 +883,14 @@ def validate_and_adapt(self, value): raise_ValueError("{} parameter {} value must be an instance of {}, not {}.".format( self.__class__.__name__, self.name, self._get_class_name(), value), self) else: - if not issubclass(value, self.class_): - raise_ValueError("{} parameter {} must be a subclass of {}, not {}.".format( - self.__class__.__name__, self.name, self._get_class_name(), value.__name__), self) + try: + if not issubclass(value, self.class_): + raise_ValueError("{} parameter {} must be a subclass of {}, not {}.".format( + self.__class__.__name__, self.name, self._get_class_name(), value.__name__), self) + except TypeError as ex: + if str(ex).startswith("ssubclass() arg 1 must be a class"): + raise_ValueError("Value must be a class, not an instance.", self) + raise ex from None # raise other type errors anyway return value @property diff --git a/hololinked/rpc/__init__.py b/hololinked/rpc/__init__.py index b68297c..60a3fee 100644 --- a/hololinked/rpc/__init__.py +++ b/hololinked/rpc/__init__.py @@ -11,6 +11,6 @@ def remote_method(URL_path : str = USE_OBJECT_NAME, http_method : str = HTTP_METHODS.POST, state : typing.Optional[typing.Union[str, Enum]] = None, argument_schema : typing.Optional[JSON] = None, - return_value_schema : typing.Optional[JSON] = None) -> typing.Callable: + return_value_schema : typing.Optional[JSON] = None, **kwargs) -> typing.Callable: return action(URL_path=URL_path, http_method=http_method, state=state, argument_schema=argument_schema, - return_value_schema=return_value_schema) \ No newline at end of file + return_value_schema=return_value_schema, **kwargs) \ No newline at end of file diff --git a/hololinked/server/HTTPServer.py b/hololinked/server/HTTPServer.py index c32f621..6c1f5c7 100644 --- a/hololinked/server/HTTPServer.py +++ b/hololinked/server/HTTPServer.py @@ -9,18 +9,21 @@ from tornado.web import Application from tornado.httpserver import HTTPServer as TornadoHTTP1Server from tornado.httpclient import AsyncHTTPClient, HTTPRequest -# from tornado_http2.server import Server as TornadoHTTP2Server +# from tornado_http2.server import Server as TornadoHTTP2Server from ..param import Parameterized from ..param.parameters import (Integer, IPAddress, ClassSelector, Selector, TypedList, String) -from .constants import CommonRPC, HTTPServerTypes, ResourceTypes, ServerMessage +from .constants import ZMQ_PROTOCOLS, CommonRPC, HTTPServerTypes, ResourceTypes, ServerMessage from .utils import get_IP_from_interface -from .data_classes import HTTPResource, ServerSentEvent -from .utils import get_default_logger, run_coro_sync +from .dataklasses import HTTPResource, ServerSentEvent +from .utils import get_default_logger from .serializers import JSONSerializer from .database import ThingInformation from .zmq_message_brokers import AsyncZMQClient, MessageMappedZMQClientPool -from .handlers import RPCHandler, BaseHandler, EventHandler, ThingsHandler +from .handlers import RPCHandler, BaseHandler, EventHandler, ThingsHandler, StopHandler +from .schema_validators import BaseSchemaValidator, JsonSchemaValidator +from .eventloop import EventLoop +from .config import global_config @@ -40,7 +43,8 @@ class HTTPServer(Parameterized): # When no SSL configurations are provided, defaults to 1.1" ) # type: float logger = ClassSelector(class_=logging.Logger, default=None, allow_None=True, doc="logging.Logger" ) # type: logging.Logger - log_level = Selector(objects=[logging.DEBUG, logging.INFO, logging.ERROR, logging.CRITICAL, logging.ERROR], + log_level = Selector(objects=[logging.DEBUG, logging.INFO, logging.ERROR, logging.WARN, + logging.CRITICAL, logging.ERROR], default=logging.INFO, doc="""alternative to logger, this creates an internal logger with the specified log level along with a IO stream handler.""" ) # type: int @@ -68,11 +72,17 @@ class HTTPServer(Parameterized): doc="custom web request handler of your choice for property read-write & action execution" ) # type: typing.Union[BaseHandler, RPCHandler] event_handler = ClassSelector(default=EventHandler, class_=(EventHandler, BaseHandler), isinstance=False, doc="custom event handler of your choice for handling events") # type: typing.Union[BaseHandler, EventHandler] + schema_validator = ClassSelector(class_=BaseSchemaValidator, default=JsonSchemaValidator, allow_None=True, isinstance=False, + doc="""Validator for JSON schema. If not supplied, a default JSON schema validator is created.""") # type: BaseSchemaValidator + + def __init__(self, things : typing.List[str], *, port : int = 8080, address : str = '0.0.0.0', host : typing.Optional[str] = None, logger : typing.Optional[logging.Logger] = None, log_level : int = logging.INFO, serializer : typing.Optional[JSONSerializer] = None, ssl_context : typing.Optional[ssl.SSLContext] = None, - certfile : str = None, keyfile : str = None, # protocol_version : int = 1, network_interface : str = 'Ethernet', + schema_validator : typing.Optional[BaseSchemaValidator] = JsonSchemaValidator, + certfile : str = None, keyfile : str = None, + # protocol_version : int = 1, network_interface : str = 'Ethernet', allowed_clients : typing.Optional[typing.Union[str, typing.Iterable[str]]] = None, **kwargs) -> None: """ @@ -114,6 +124,7 @@ def __init__(self, things : typing.List[str], *, port : int = 8080, address : st log_level=log_level, serializer=serializer or JSONSerializer(), # protocol_version=1, + schema_validator=schema_validator, certfile=certfile, keyfile=keyfile, ssl_context=ssl_context, @@ -124,9 +135,9 @@ def __init__(self, things : typing.List[str], *, port : int = 8080, address : st ) self._type = HTTPServerTypes.THING_SERVER self._lost_things = dict() # see update_router_with_thing - # self._zmq_protocol = zmq_protocol - # self._zmq_socket_context = context - # self._zmq_event_context = context + self._zmq_protocol = ZMQ_PROTOCOLS.IPC + self._zmq_socket_context = None + self._zmq_event_context = None @property def all_ok(self) -> bool: @@ -138,20 +149,28 @@ def all_ok(self) -> bool: self.app = Application(handlers=[ (r'/remote-objects', ThingsHandler, dict(request_handler=self.request_handler, - event_handler=self.event_handler)) + event_handler=self.event_handler)), + (r'/stop', StopHandler, dict(owner=self)) ]) self.zmq_client_pool = MessageMappedZMQClientPool(self.things, identity=self._IP, deserialize_server_messages=False, handshake=False, - json_serializer=self.serializer, context=self._zmq_socket_context, - protocol=self._zmq_protocol) - - event_loop = asyncio.get_event_loop() + http_serializer=self.serializer, + context=self._zmq_socket_context, + protocol=self._zmq_protocol, + logger=self.logger + ) + # print("client pool context", self.zmq_client_pool.context) + event_loop = EventLoop.get_async_loop() # sets async loop for a non-possessing thread as well event_loop.call_soon(lambda : asyncio.create_task(self.update_router_with_things())) event_loop.call_soon(lambda : asyncio.create_task(self.subscribe_to_host())) event_loop.call_soon(lambda : asyncio.create_task(self.zmq_client_pool.poll()) ) for client in self.zmq_client_pool: event_loop.call_soon(lambda : asyncio.create_task(client._handshake(timeout=60000))) + + self.tornado_event_loop = None + # set value based on what event loop we use, there is some difference + # between the asyncio event loop and the tornado event loop # if self.protocol_version == 2: # raise NotImplementedError("Current HTTP2 is not implemented.") @@ -201,17 +220,24 @@ def listen(self) -> None: the inner tornado instance's (``HTTPServer.tornado_instance``) listen() method. """ assert self.all_ok, 'HTTPServer all is not ok before starting' # Will always be True or cause some other exception - self.event_loop = ioloop.IOLoop.current() + self.tornado_event_loop = ioloop.IOLoop.current() self.tornado_instance.listen(port=self.port, address=self.address) self.logger.info(f'started webserver at {self._IP}, ready to receive requests.') - self.event_loop.start() - - def stop(self) -> None: - self.tornado_instance.stop() - run_coro_sync(self.tornado_instance.close_all_connections()) - self.event_loop.close() + self.tornado_event_loop.start() + async def stop(self) -> None: + """ + Stop the event loop & the HTTP server. This method is async and should be awaited, mostly within a request + handler. The stop handler at the path '/stop' with POST request is already implemented. + """ + self.tornado_instance.stop() + self.zmq_client_pool.stop_polling() + await self.tornado_instance.close_all_connections() + if self.tornado_event_loop is not None: + self.tornado_event_loop.stop() + + async def update_router_with_things(self) -> None: """ updates HTTP router with paths from ``Thing`` (s) @@ -237,16 +263,18 @@ async def update_router_with_thing(self, client : AsyncZMQClient): handlers = [] for instruction, http_resource in resources.items(): - if http_resource["what"] in [ResourceTypes.PROPERTY, ResourceTypes.ACTION] : + if http_resource["what"] in [ResourceTypes.PROPERTY, ResourceTypes.ACTION]: resource = HTTPResource(**http_resource) handlers.append((resource.fullpath, self.request_handler, dict( - resource=resource, + resource=resource, + validator=self.schema_validator(resource.argument_schema) if global_config.validate_schema_on_client and resource.argument_schema else None, owner=self ))) elif http_resource["what"] == ResourceTypes.EVENT: resource = ServerSentEvent(**http_resource) handlers.append((instruction, self.event_handler, dict( resource=resource, + validator=None, owner=self ))) """ diff --git a/hololinked/server/action.py b/hololinked/server/action.py index 1e95069..5d05bc8 100644 --- a/hololinked/server/action.py +++ b/hololinked/server/action.py @@ -1,16 +1,22 @@ import typing +import jsonschema from enum import Enum -from types import FunctionType +from types import FunctionType, MethodType from inspect import iscoroutinefunction, getfullargspec -from .data_classes import RemoteResourceInfoValidator +from ..param.parameterized import ParameterizedFunction +from .utils import issubklass, pep8_to_URL_path, isclassmethod +from .dataklasses import ActionInfoValidator from .constants import USE_OBJECT_NAME, UNSPECIFIED, HTTP_METHODS, JSON +from .config import global_config + +__action_kw_arguments__ = ['safe', 'idempotent', 'synchronous'] def action(URL_path : str = USE_OBJECT_NAME, http_method : str = HTTP_METHODS.POST, state : typing.Optional[typing.Union[str, Enum]] = None, input_schema : typing.Optional[JSON] = None, - output_schema : typing.Optional[JSON] = None, create_task : bool = False) -> typing.Callable: + output_schema : typing.Optional[JSON] = None, create_task : bool = False, **kwargs) -> typing.Callable: """ Use this function as a decorate on your methods to make them accessible remotely. For WoT, an action affordance schema for the method is generated. @@ -26,9 +32,15 @@ def action(URL_path : str = USE_OBJECT_NAME, http_method : str = HTTP_METHODS.PO the action can be executed under any state. input_schema: JSON schema for arguments to validate them. - output_schema : JSON + output_schema: JSON schema for return value, currently only used to inform clients which is supposed to validate on its won. - + **kwargs: + safe: bool + indicate in thing description if action is safe to execute + idempotent: bool + indicate in thing description if action is idempotent (for example, allows HTTP client to cache return value) + synchronous: bool + indicate in thing description if action is synchronous (not long running) Returns ------- Callable @@ -37,61 +49,77 @@ def action(URL_path : str = USE_OBJECT_NAME, http_method : str = HTTP_METHODS.PO def inner(obj): original = obj - if isinstance(obj, classmethod): + if (not isinstance(obj, (FunctionType, MethodType)) and not isclassmethod(obj) and + not issubklass(obj, ParameterizedFunction)): + raise TypeError(f"target for action or is not a function/method. Given type {type(obj)}") from None + if isclassmethod(obj): obj = obj.__func__ if obj.__name__.startswith('__'): raise ValueError(f"dunder objects cannot become remote : {obj.__name__}") - if callable(obj): - if hasattr(obj, '_remote_info') and not isinstance(obj._remote_info, RemoteResourceInfoValidator): - raise NameError( - "variable name '_remote_info' reserved for hololinked package. ", - "Please do not assign this variable to any other object except hololinked.server.data_classes.RemoteResourceInfoValidator." - ) + if hasattr(obj, '_remote_info') and not isinstance(obj._remote_info, ActionInfoValidator): + raise NameError( + "variable name '_remote_info' reserved for hololinked package. ", + "Please do not assign this variable to any other object except hololinked.server.dataklasses.ActionInfoValidator." + ) + else: + obj._remote_info = ActionInfoValidator() + obj_name = obj.__qualname__.split('.') + if len(obj_name) > 1: # i.e. its a bound method, used by Thing + if URL_path == USE_OBJECT_NAME: + obj._remote_info.URL_path = f'/{pep8_to_URL_path(obj_name[1])}' else: - obj._remote_info = RemoteResourceInfoValidator() - obj_name = obj.__qualname__.split('.') - if len(obj_name) > 1: # i.e. its a bound method, used by Thing - if URL_path == USE_OBJECT_NAME: - obj._remote_info.URL_path = f'/{obj_name[1]}' - else: - if not URL_path.startswith('/'): - raise ValueError(f"URL_path should start with '/', please add '/' before '{URL_path}'") - obj._remote_info.URL_path = URL_path - obj._remote_info.obj_name = obj_name[1] - elif len(obj_name) == 1 and isinstance(obj, FunctionType): # normal unbound function - used by HTTPServer instance - if URL_path is USE_OBJECT_NAME: - obj._remote_info.URL_path = '/{}'.format(obj_name[0]) - else: - if not URL_path.startswith('/'): - raise ValueError(f"URL_path should start with '/', please add '/' before '{URL_path}'") - obj._remote_info.URL_path = URL_path - obj._remote_info.obj_name = obj_name[0] + if not URL_path.startswith('/'): + raise ValueError(f"URL_path should start with '/', please add '/' before '{URL_path}'") + obj._remote_info.URL_path = URL_path + obj._remote_info.obj_name = obj_name[1] + elif len(obj_name) == 1 and isinstance(obj, FunctionType): # normal unbound function - used by HTTPServer instance + if URL_path is USE_OBJECT_NAME: + obj._remote_info.URL_path = f'/{pep8_to_URL_path(obj_name[0])}' else: - raise RuntimeError(f"Undealt option for decorating {obj} or decorators wrongly used") - if http_method is not UNSPECIFIED: - if isinstance(http_method, str): - obj._remote_info.http_method = (http_method,) - else: - obj._remote_info.http_method = http_method - if state is not None: - if isinstance(state, (Enum, str)): - obj._remote_info.state = (state,) - else: - obj._remote_info.state = state - if 'request' in getfullargspec(obj).kwonlyargs: - obj._remote_info.request_as_argument = True - obj._remote_info.isaction = True - obj._remote_info.iscoroutine = iscoroutinefunction(obj) - obj._remote_info.argument_schema = input_schema - obj._remote_info.return_value_schema = output_schema - obj._remote_info.obj = original - return original + if not URL_path.startswith('/'): + raise ValueError(f"URL_path should start with '/', please add '/' before '{URL_path}'") + obj._remote_info.URL_path = URL_path + obj._remote_info.obj_name = obj_name[0] + else: + raise RuntimeError(f"Undealt option for decorating {obj} or decorators wrongly used") + if http_method is not UNSPECIFIED: + if isinstance(http_method, str): + obj._remote_info.http_method = (http_method,) + else: + obj._remote_info.http_method = http_method + if state is not None: + if isinstance(state, (Enum, str)): + obj._remote_info.state = (state,) + else: + obj._remote_info.state = state + if 'request' in getfullargspec(obj).kwonlyargs: + obj._remote_info.request_as_argument = True + obj._remote_info.isaction = True + obj._remote_info.argument_schema = input_schema + obj._remote_info.return_value_schema = output_schema + obj._remote_info.obj = original + obj._remote_info.create_task = create_task + obj._remote_info.safe = kwargs.get('safe', False) + obj._remote_info.idempotent = kwargs.get('idempotent', False) + obj._remote_info.synchronous = kwargs.get('synchronous', False) + + if issubklass(obj, ParameterizedFunction): + obj._remote_info.iscoroutine = iscoroutinefunction(obj.__call__) + obj._remote_info.isparameterized = True else: - raise TypeError( - "target for action or is not a function/method. " + - f"Given type {type(obj)}" - ) - + obj._remote_info.iscoroutine = iscoroutinefunction(obj) + obj._remote_info.isparameterized = False + if global_config.validate_schemas and input_schema: + jsonschema.Draft7Validator.check_schema(input_schema) + if global_config.validate_schemas and output_schema: + jsonschema.Draft7Validator.check_schema(output_schema) + + return original + if callable(URL_path): + raise TypeError("URL_path should be a string, not a function/method, did you decorate your action wrongly?") + if any(key not in __action_kw_arguments__ for key in kwargs.keys()): + raise ValueError("Only 'safe', 'idempotent', 'synchronous' are allowed as keyword arguments, " + + f"unknown arguments found {kwargs.keys()}") return inner diff --git a/hololinked/server/api_platform_utils.py b/hololinked/server/api_platforms.py similarity index 98% rename from hololinked/server/api_platform_utils.py rename to hololinked/server/api_platforms.py index 9bc14db..ea5cba7 100644 --- a/hololinked/server/api_platform_utils.py +++ b/hololinked/server/api_platforms.py @@ -28,7 +28,7 @@ def save_json_file(self, filename = 'collection.json'): @classmethod def build(cls, instance, domain_prefix : str) -> Dict[str, Any]: from .thing import Thing - from .data_classes import HTTPResource, RemoteResource + from .dataklasses import HTTPResource, RemoteResource assert isinstance(instance, Thing) # type definition try: return instance._postman_collection diff --git a/hololinked/server/config.py b/hololinked/server/config.py index 31bd37d..d502eec 100644 --- a/hololinked/server/config.py +++ b/hololinked/server/config.py @@ -85,7 +85,8 @@ class Configuration: # credentials "PWD_HASHER_TIME_COST", "PWD_HASHER_MEMORY_COST", # Eventloop - "USE_UVLOOP" + "USE_UVLOOP", "TRACE_MALLOC", + 'validate_schema_on_client', 'validate_schemas' ] def __init__(self, use_environment : bool = False): @@ -102,6 +103,9 @@ def load_variables(self, use_environment : bool = False): self.TCP_SOCKET_SEARCH_END_PORT = 65535 self.PWD_HASHER_TIME_COST = 15 self.USE_UVLOOP = False + self.TRACE_MALLOC = False + self.validate_schema_on_client = False + self.validate_schemas = True if not use_environment: return diff --git a/hololinked/server/constants.py b/hololinked/server/constants.py index d38b538..17d7918 100644 --- a/hololinked/server/constants.py +++ b/hololinked/server/constants.py @@ -33,14 +33,14 @@ class ResourceTypes(StrEnum): class CommonRPC(StrEnum): """some common RPC and their associated instructions for quick access by lower level code""" - RPC_RESOURCES = '/resources/object-proxy' + ZMQ_RESOURCES = '/resources/zmq-object-proxy' HTTP_RESOURCES = '/resources/http-server' OBJECT_INFO = '/object-info' PING = '/ping' @classmethod - def rpc_resource_read(cls, instance_name : str) -> str: - return f"/{instance_name}{cls.RPC_RESOURCES}/read" + def zmq_resource_read(cls, instance_name : str) -> str: + return f"/{instance_name}{cls.ZMQ_RESOURCES}/read" @classmethod def http_resource_read(cls, instance_name : str) -> str: diff --git a/hololinked/server/database.py b/hololinked/server/database.py index 1d863b9..ec540d8 100644 --- a/hololinked/server/database.py +++ b/hololinked/server/database.py @@ -12,7 +12,7 @@ from ..param import Parameterized from .constants import JSONSerializable from .config import global_config -from .utils import pep8_to_dashed_URL +from .utils import pep8_to_URL_path from .serializers import PythonBuiltinJSONSerializer as JSONSerializer, BaseSerializer from .property import Property @@ -103,7 +103,7 @@ def create_URL(self, config_file : str) -> str: auto chooses among the different supported databases based on config file and creates the DB URL """ if config_file is None: - folder = f'{global_config.TEMP_DIR}{os.sep}databases{os.sep}{pep8_to_dashed_URL(self.thing_instance.__class__.__name__.lower())}' + folder = self.get_temp_dir_for_class_name(self.thing_instance.__class__.__name__) if not os.path.exists(folder): os.makedirs(folder) return BaseDB.create_sqlite_URL(**dict(file=f'{folder}{os.sep}{self.instance_name}.db')) @@ -113,6 +113,13 @@ def create_URL(self, config_file : str) -> str: else: return BaseDB.create_sqlite_URL(conf=conf) + @classmethod + def get_temp_dir_for_class_name(self, class_name : str) -> str: + """ + get temporary directory for database files + """ + return f"{global_config.TEMP_DIR}{os.sep}databases{os.sep}{pep8_to_URL_path(class_name)}" + @classmethod def create_postgres_URL(cls, conf : str = None, database : typing.Optional[str] = None, use_dialect : typing.Optional[bool] = False) -> str: @@ -162,7 +169,7 @@ class BaseAsyncDB(BaseDB): The database to open in the database server specified in config_file (see below) serializer: BaseSerializer The serializer to use for serializing and deserializing data (for example - property serializing before writing to database). Will be the same as rpc_serializer supplied to ``Thing``. + property serializing before writing to database). Will be the same as zmq_serializer supplied to ``Thing``. config_file: str absolute path to database server configuration file """ @@ -171,7 +178,7 @@ def __init__(self, instance : Parameterized, serializer : typing.Optional[BaseSerializer] = None, config_file : typing.Union[str, None] = None) -> None: super().__init__(instance=instance, serializer=serializer, config_file=config_file) - self.engine = asyncio_ext.create_async_engine(self.URL, echo=True) + self.engine = asyncio_ext.create_async_engine(self.URL) self.async_session = sessionmaker(self.engine, expire_on_commit=True, class_=asyncio_ext.AsyncSession) ThingTableBase.metadata.create_all(self.engine) @@ -190,7 +197,7 @@ class BaseSyncDB(BaseDB): serializer: BaseSerializer The serializer to use for serializing and deserializing data (for example property serializing into database for storage). Will be the same as - rpc_serializer supplied to ``Thing``. + zmq_serializer supplied to ``Thing``. config_file: str absolute path to database server configuration file """ @@ -199,7 +206,7 @@ def __init__(self, instance : Parameterized, serializer : typing.Optional[BaseSerializer] = None, config_file : typing.Union[str, None] = None) -> None: super().__init__(instance=instance, serializer=serializer, config_file=config_file) - self.engine = create_engine(self.URL, echo=True) + self.engine = create_engine(self.URL) self.sync_session = sessionmaker(self.engine, expire_on_commit=True) ThingTableBase.metadata.create_all(self.engine) @@ -389,7 +396,7 @@ def get_all_properties(self, deserialized : bool = True) -> typing.Dict[str, typ with self.sync_session() as session: stmt = select(SerializedProperty).filter_by(instance_name=self.instance_name) data = session.execute(stmt) - existing_props = data.scalars().all() #type: typing.Sequence[SerializedProperty] + existing_props = data.scalars().all() # type: typing.Sequence[SerializedProperty] if not deserialized: return existing_props props = dict() diff --git a/hololinked/server/data_classes.py b/hololinked/server/dataklasses.py similarity index 78% rename from hololinked/server/data_classes.py rename to hololinked/server/dataklasses.py index 7e685f4..aca9107 100644 --- a/hololinked/server/data_classes.py +++ b/hololinked/server/dataklasses.py @@ -9,10 +9,12 @@ from dataclasses import dataclass, asdict, field, fields from types import FunctionType, MethodType -from ..param.parameters import String, Boolean, Tuple, TupleSelector, TypedDict, ClassSelector, Parameter -from .constants import JSON, USE_OBJECT_NAME, UNSPECIFIED, HTTP_METHODS, REGEX, ResourceTypes, http_methods -from .utils import get_signature, getattr_without_descriptor_read - +from ..param.parameters import String, Boolean, Tuple, TupleSelector, ClassSelector, Parameter +from ..param.parameterized import ParameterizedMetaclass, ParameterizedFunction +from .constants import JSON, USE_OBJECT_NAME, UNSPECIFIED, HTTP_METHODS, REGEX, ResourceTypes, http_methods +from .utils import get_signature, getattr_without_descriptor_read, pep8_to_URL_path +from .config import global_config +from .schema_validators import BaseSchemaValidator class RemoteResourceInfoValidator: @@ -23,7 +25,6 @@ class RemoteResourceInfoValidator: Attributes ---------- - URL_path : str, default - extracted object name the path in the URL under which the object is accesible. Must follow url-regex ('[\-a-zA-Z0-9@:%._\/\+~#=]{1,256}') requirement. @@ -44,38 +45,22 @@ class RemoteResourceInfoValidator: True for a method or function or callable isproperty : bool, default False True for a property - request_as_argument : bool, default False - if True, http/RPC request object will be passed as an argument to the callable. - The user is warned to not use this generally. - argument_schema: JSON, default None - JSON schema validations for arguments of a callable. Assumption is therefore arguments will be JSON complaint. - return_value_schema: JSON, default None - schema for return value of a callable """ - URL_path = String(default=USE_OBJECT_NAME, doc="the path in the URL under which the object is accesible.") # type: str http_method = TupleSelector(default=HTTP_METHODS.POST, objects=http_methods, accept_list=True, doc="HTTP request method under which the object is accessible. GET, POST, PUT, DELETE or PATCH are supported.") # typing.Tuple[str] state = Tuple(default=None, item_type=(Enum, str), allow_None=True, accept_list=True, accept_item=True, doc="State machine state at which a callable will be executed or attribute/property can be written.") # type: typing.Union[Enum, str] - obj = ClassSelector(default=None, allow_None=True, class_=(FunctionType, classmethod, Parameter, MethodType), # Property will need circular import so we stick to Parameter + obj = ClassSelector(default=None, allow_None=True, class_=(FunctionType, MethodType, classmethod, Parameter, ParameterizedMetaclass), # Property will need circular import so we stick to base class Parameter doc="the unbound object like the unbound method") obj_name = String(default=USE_OBJECT_NAME, doc="the name of the object which will be supplied to the ``ObjectProxy`` class to populate its own namespace.") # type: str - iscoroutine = Boolean(default=False, - doc="whether the callable should be awaited") # type: bool isaction = Boolean(default=False, doc="True for a method or function or callable") # type: bool isproperty = Boolean(default=False, doc="True for a property") # type: bool - request_as_argument = Boolean(default=False, - doc="if True, http/RPC request object will be passed as an argument to the callable.") # type: bool - argument_schema = TypedDict(default=None, allow_None=True, key_type=str, - doc="JSON schema validations for arguments of a callable") - return_value_schema = TypedDict(default=None, allow_None=True, key_type=str, - doc="schema for return value of a callable") - + def __init__(self, **kwargs) -> None: """ No full-scale checks for unknown keyword arguments as the class @@ -109,10 +94,61 @@ def to_dataclass(self, obj : typing.Any = None, bound_obj : typing.Any = None) - """ return RemoteResource( state=tuple(self.state) if self.state is not None else None, - obj_name=self.obj_name, isaction=self.isaction, iscoroutine=self.iscoroutine, - isproperty=self.isproperty, obj=obj, bound_obj=bound_obj + obj_name=self.obj_name, isaction=self.isaction, + isproperty=self.isproperty, obj=obj, bound_obj=bound_obj, ) # http method is manually always stored as a tuple + + +class ActionInfoValidator(RemoteResourceInfoValidator): + """ + request_as_argument : bool, default False + if True, http/RPC request object will be passed as an argument to the callable. + The user is warned to not use this generally. + argument_schema: JSON, default None + JSON schema validations for arguments of a callable. Assumption is therefore arguments will be JSON complaint. + return_value_schema: JSON, default None + schema for return value of a callable. Assumption is therefore return value will be JSON complaint. + create_task: bool, default True + default for async methods/actions + safe: bool, default True + metadata information whether the action is safe to execute + idempotent: bool, default False + metadata information whether the action is idempotent + synchronous: bool, default True + metadata information whether the action is synchronous + """ + request_as_argument = Boolean(default=False, + doc="if True, http/RPC request object will be passed as an argument to the callable.") # type: bool + argument_schema = ClassSelector(default=None, allow_None=True, class_=dict, + # due to schema validation, this has to be a dict, and not a special dict like TypedDict + doc="JSON schema validations for arguments of a callable") + return_value_schema = ClassSelector(default=None, allow_None=True, class_=dict, + # due to schema validation, this has to be a dict, and not a special dict like TypedDict + doc="schema for return value of a callable") + create_task = Boolean(default=True, + doc="should a coroutine be tasked or run in the same loop?") # type: bool + iscoroutine = Boolean(default=False, # not sure if isFuture or isCoroutine is correct, something to fix later + doc="whether the callable should be awaited") # type: bool + safe = Boolean(default=True, + doc="metadata information whether the action is safe to execute") # type: bool + idempotent = Boolean(default=False, + doc="metadata information whether the action is idempotent") # type: bool + synchronous = Boolean(default=True, + doc="metadata information whether the action is synchronous") # type: bool + isparameterized = Boolean(default=False, + doc="True for a parameterized function") # type: bool + + + def to_dataclass(self, obj : typing.Any = None, bound_obj : typing.Any = None) -> "RemoteResource": + return ActionResource( + state=tuple(self.state) if self.state is not None else None, + obj_name=self.obj_name, isaction=self.isaction, iscoroutine=self.iscoroutine, + isproperty=self.isproperty, obj=obj, bound_obj=bound_obj, + schema_validator=(bound_obj.schema_validator)(self.argument_schema) if not global_config.validate_schema_on_client and self.argument_schema else None, + create_task=self.create_task, isparameterized=self.isparameterized + ) + class SerializableDataclass: @@ -120,7 +156,6 @@ class SerializableDataclass: Presents uniform serialization for serializers using getstate and setstate and json serialization. """ - def json(self): return asdict(self) @@ -132,7 +167,6 @@ def __setstate__(self, values : typing.Dict): setattr(self, key, value) - __dataclass_kwargs = dict(frozen=True) if float('.'.join(platform.python_version().split('.')[0:2])) >= 3.11: __dataclass_kwargs["slots"] = True @@ -154,8 +188,6 @@ class RemoteResource(SerializableDataclass): the name of the object which will be supplied to the ``ObjectProxy`` class to populate its own namespace. For HTTP clients, HTTP method and URL path is important and for object proxies clients, the obj_name is important. - iscoroutine : bool - whether the callable should be awaited isaction : bool True for a method or function or callable isproperty : bool @@ -168,11 +200,10 @@ class RemoteResource(SerializableDataclass): state : typing.Optional[typing.Union[typing.Tuple, str]] obj_name : str isaction : bool - iscoroutine : bool isproperty : bool obj : typing.Any bound_obj : typing.Any - + def json(self): """ return this object as a JSON serializable dictionary @@ -185,8 +216,25 @@ def json(self): if field.name != 'obj' and field.name != 'bound_obj': json_dict[field.name] = getattr(self, field.name) # object.__setattr__(self, '_json', json_dict) # because object is frozen - used to work, but not now - return json_dict - + return json_dict + + +@dataclass(**__dataclass_kwargs) +class ActionResource(RemoteResource): + """ + Attributes + ---------- + iscoroutine : bool + whether the callable should be awaited + schema_validator : BaseSchemaValidator + schema validator for the callable if to be validated server side + """ + iscoroutine : bool + schema_validator : typing.Optional[BaseSchemaValidator] + create_task : bool + isparameterized : bool + # no need safe, idempotent, synchronous + @dataclass class HTTPMethodInstructions(SerializableDataclass): @@ -247,19 +295,18 @@ class HTTPResource(SerializableDataclass): fullpath : str instructions : HTTPMethodInstructions argument_schema : typing.Optional[JSON] - return_value_schema : typing.Optional[JSON] request_as_argument : bool = field(default=False) + def __init__(self, *, what : str, instance_name : str, obj_name : str, fullpath : str, request_as_argument : bool = False, argument_schema : typing.Optional[JSON] = None, - return_value_schema : typing.Optional[JSON] = None, **instructions) -> None: + **instructions) -> None: self.what = what self.instance_name = instance_name self.obj_name = obj_name self.fullpath = fullpath self.request_as_argument = request_as_argument self.argument_schema = argument_schema - self.return_value_schema = return_value_schema if instructions.get('instructions', None): self.instructions = HTTPMethodInstructions(**instructions.get('instructions', None)) else: @@ -267,7 +314,7 @@ def __init__(self, *, what : str, instance_name : str, obj_name : str, fullpath @dataclass -class RPCResource(SerializableDataclass): +class ZMQResource(SerializableDataclass): """ Representation of resource used by RPC clients for mapping client method/action calls, property read/writes & events to a server resource. Used to dynamically populate the ``ObjectProxy`` @@ -341,12 +388,13 @@ class ServerSentEvent(SerializableDataclass): what: str, default EVENT is it a property, method/action or event? """ - name : str - obj_name : str - unique_identifier : str + name : str = field(default=UNSPECIFIED) + obj_name : str = field(default=UNSPECIFIED) + unique_identifier : str = field(default=UNSPECIFIED) socket_address : str = field(default=UNSPECIFIED) what : str = field(default=ResourceTypes.EVENT) + @dataclass class GUIResources(SerializableDataclass): @@ -389,20 +437,24 @@ def __init__(self): def build(self, instance): from .thing import Thing + from .events import Event + assert isinstance(instance, Thing), f"got invalid type {type(instance)}" self.instance_name = instance.instance_name self.inheritance = [class_.__name__ for class_ in instance.__class__.mro()] self.classdoc = instance.__class__.__doc__.splitlines() if instance.__class__.__doc__ is not None else None self.GUI = instance.GUI + self.events = { event._unique_identifier.decode() : dict( - name = event.name, + name = event._remote_info.name, instruction = event._unique_identifier.decode(), - owner = event.owner.__class__.__name__, - owner_instance_name = event.owner.instance_name, + owner = event._owner_inst.__class__.__name__, + owner_instance_name = event._owner_inst.instance_name, address = instance.event_publisher.socket_address ) for event in instance.event_publisher.events + } self.actions = dict() self.properties = dict() @@ -410,12 +462,12 @@ def build(self, instance): for instruction, remote_info in instance.instance_resources.items(): if remote_info.isaction: try: - self.actions[instruction] = instance.rpc_resources[instruction].json() + self.actions[instruction] = instance.zmq_resources[instruction].json() self.actions[instruction]["remote_info"] = instance.httpserver_resources[instruction].json() self.actions[instruction]["remote_info"]["http_method"] = instance.httpserver_resources[instruction].instructions.supported_methods() # to check - apparently the recursive json() calling does not reach inner depths of a dict, # therefore we call json ourselves - self.actions[instruction]["owner"] = instance.rpc_resources[instruction].qualname.split('.')[0] + self.actions[instruction]["owner"] = instance.zmq_resources[instruction].qualname.split('.')[0] self.actions[instruction]["owner_instance_name"] = remote_info.bound_obj.instance_name self.actions[instruction]["type"] = 'classmethod' if isinstance(remote_info.obj, classmethod) else '' self.actions[instruction]["signature"] = get_signature(remote_info.obj)[0] @@ -443,16 +495,16 @@ def get_organised_resources(instance): so that the specific servers and event loop can use them. """ from .thing import Thing - from .events import Event + from .events import Event, EventDispatcher from .property import Property assert isinstance(instance, Thing), f"got invalid type {type(instance)}" httpserver_resources = dict() # type: typing.Dict[str, HTTPResource] # The following dict will be given to the object proxy client - rpc_resources = dict() # type: typing.Dict[str, RPCResource] + zmq_resources = dict() # type: typing.Dict[str, ZMQResource] # The following dict will be used by the event loop - instance_resources = dict() # type: typing.Dict[str, RemoteResource] + instance_resources = dict() # type: typing.Dict[str, typing.Union[RemoteResource, ActionResource]] # create URL prefix if instance._owner is not None: instance._full_URL_path_prefix = f'{instance._owner._full_URL_path_prefix}/{instance.instance_name}' @@ -469,22 +521,32 @@ def get_organised_resources(instance): # above condition is just a gaurd in case somebody does some unpredictable patching activities remote_info = prop._remote_info fullpath = f"{instance._full_URL_path_prefix}{remote_info.URL_path}" - read_http_method, write_http_method = remote_info.http_method + read_http_method = write_http_method = delete_http_method = None + if len(remote_info.http_method) == 1: + read_http_method = remote_info.http_method[0] + instructions = { read_http_method : f"{fullpath}/read" } + elif len(remote_info.http_method) == 2: + read_http_method, write_http_method = remote_info.http_method + instructions = { + read_http_method : f"{fullpath}/read", + write_http_method : f"{fullpath}/write" + } + else: + read_http_method, write_http_method, delete_http_method = remote_info.http_method + instructions = { + read_http_method : f"{fullpath}/read", + write_http_method : f"{fullpath}/write", + delete_http_method : f"{fullpath}/delete" + } httpserver_resources[fullpath] = HTTPResource( what=ResourceTypes.PROPERTY, instance_name=instance._owner.instance_name if instance._owner is not None else instance.instance_name, obj_name=remote_info.obj_name, fullpath=fullpath, - request_as_argument=False, - argument_schema=remote_info.argument_schema, - return_value_schema=remote_info.return_value_schema, - **{ - read_http_method : f"{fullpath}/read", - write_http_method : f"{fullpath}/write" - } + **instructions ) - rpc_resources[fullpath] = RPCResource( + zmq_resources[fullpath] = ZMQResource( what=ResourceTypes.PROPERTY, instance_name=instance._owner.instance_name if instance._owner is not None else instance.instance_name, instruction=fullpath, @@ -493,47 +555,45 @@ def get_organised_resources(instance): qualname=instance.__class__.__name__ + '.' + remote_info.obj_name, # qualname is not correct probably, does not respect inheritance top_owner=instance._owner is None, - argument_schema=remote_info.argument_schema, - return_value_schema=remote_info.return_value_schema ) data_cls = remote_info.to_dataclass(obj=prop, bound_obj=instance) instance_resources[f"{fullpath}/read"] = data_cls instance_resources[f"{fullpath}/write"] = data_cls - # instance_resources[f"{fullpath}/delete"] = data_cls - if prop.observable: - event_data_cls = ServerSentEvent( - name=prop._observable_event.name, - obj_name='_observable_event', # not used in client, so fill it with something - what=ResourceTypes.EVENT, - unique_identifier=f"{instance._full_URL_path_prefix}{prop._observable_event.URL_path}", - ) - prop._observable_event._remote_info = event_data_cls - httpserver_resources[fullpath] = event_data_cls + instance_resources[f"{fullpath}/delete"] = data_cls + if prop._observable: + # There is no real philosophy behind this logic flow, we just set the missing information. + assert isinstance(prop._observable_event_descriptor, Event), f"observable event not yet set for {prop.name}. logic error." + evt_fullpath = f"{instance._full_URL_path_prefix}{prop._observable_event_descriptor.URL_path}" + dispatcher = EventDispatcher(evt_fullpath) + setattr(instance, prop._observable_event_descriptor._obj_name, dispatcher) + # prop._observable_event_descriptor._remote_info.unique_identifier = evt_fullpath + httpserver_resources[evt_fullpath] = dispatcher._remote_info + zmq_resources[evt_fullpath] = dispatcher._remote_info # Methods - for name, resource in inspect._getmembers(instance, inspect.ismethod, getattr_without_descriptor_read): + for name, resource in inspect._getmembers(instance, lambda f : inspect.ismethod(f) or ( + hasattr(f, '_remote_info') and isinstance(f._remote_info, ActionInfoValidator)), + getattr_without_descriptor_read): if hasattr(resource, '_remote_info'): - if not isinstance(resource._remote_info, RemoteResourceInfoValidator): + if not isinstance(resource._remote_info, ActionInfoValidator): raise TypeError("instance member {} has unknown sub-member '_remote_info' of type {}.".format( resource, type(resource._remote_info))) remote_info = resource._remote_info # methods are already bound assert remote_info.isaction, ("remote info from inspect.ismethod is not a callable", "logic error - visit https://github.com/VigneshVSV/hololinked/issues to report") - if len(remote_info.http_method) > 1: - raise ValueError(f"methods support only one HTTP method at the moment. Given number of methods : {len(remote_info.http_method)}.") fullpath = f"{instance._full_URL_path_prefix}{remote_info.URL_path}" - instruction = f"{fullpath}/{remote_info.http_method[0]}" + instruction = f"{fullpath}/invoke-on-{remote_info.http_method[0]}" + # needs to be cleaned up for multiple HTTP methods httpserver_resources[instruction] = HTTPResource( what=ResourceTypes.ACTION, instance_name=instance._owner.instance_name if instance._owner is not None else instance.instance_name, obj_name=remote_info.obj_name, fullpath=fullpath, - request_as_argument=remote_info.request_as_argument, argument_schema=remote_info.argument_schema, - return_value_schema=remote_info.return_value_schema, - **{ remote_info.http_method[0] : instruction }, + request_as_argument=remote_info.request_as_argument, + **{ http_method : instruction for http_method in remote_info.http_method }, ) - rpc_resources[instruction] = RPCResource( + zmq_resources[instruction] = ZMQResource( what=ResourceTypes.ACTION, instance_name=instance._owner.instance_name if instance._owner is not None else instance.instance_name, instruction=instruction, @@ -545,24 +605,20 @@ def get_organised_resources(instance): return_value_schema=remote_info.return_value_schema, request_as_argument=remote_info.request_as_argument ) - instance_resources[instruction] = remote_info.to_dataclass(obj=resource, bound_obj=instance) + instance_resources[instruction] = remote_info.to_dataclass(obj=resource, bound_obj=instance) # Events for name, resource in inspect._getmembers(instance, lambda o : isinstance(o, Event), getattr_without_descriptor_read): assert isinstance(resource, Event), ("thing event query from inspect.ismethod is not an Event", "logic error - visit https://github.com/VigneshVSV/hololinked/issues to report") + if getattr(instance, name, None): + continue # above assertion is only a typing convenience - resource._owner = instance fullpath = f"{instance._full_URL_path_prefix}{resource.URL_path}" - resource._unique_identifier = bytes(fullpath, encoding='utf-8') - data_cls = ServerSentEvent( - name=resource.name, - obj_name=name, - what=ResourceTypes.EVENT, - unique_identifier=f"{instance._full_URL_path_prefix}{resource.URL_path}", - ) - resource._remote_info = data_cls - httpserver_resources[fullpath] = data_cls - rpc_resources[fullpath] = data_cls + # resource._remote_info.unique_identifier = fullpath + dispatcher = EventDispatcher(fullpath) + setattr(instance, name, dispatcher) # resource._remote_info.unique_identifier)) + httpserver_resources[fullpath] = dispatcher._remote_info + zmq_resources[fullpath] = dispatcher._remote_info # Other objects for name, resource in inspect._getmembers(instance, lambda o : isinstance(o, Thing), getattr_without_descriptor_read): assert isinstance(resource, Thing), ("thing children query from inspect.ismethod is not a Thing", @@ -574,9 +630,9 @@ def get_organised_resources(instance): continue resource._owner = instance httpserver_resources.update(resource.httpserver_resources) - # rpc_resources.update(resource.rpc_resources) + # zmq_resources.update(resource.zmq_resources) instance_resources.update(resource.instance_resources) # The above for-loops can be used only once, the division is only for readability # following are in _internal_fixed_attributes - allowed to set only once - return rpc_resources, httpserver_resources, instance_resources \ No newline at end of file + return zmq_resources, httpserver_resources, instance_resources \ No newline at end of file diff --git a/hololinked/server/eventloop.py b/hololinked/server/eventloop.py index 2d6a105..4e64f98 100644 --- a/hololinked/server/eventloop.py +++ b/hololinked/server/eventloop.py @@ -7,6 +7,7 @@ import typing import threading import logging +import tracemalloc from uuid import uuid4 from .constants import HTTP_METHODS @@ -21,6 +22,10 @@ from .logger import ListHandler +if global_config.TRACE_MALLOC: + tracemalloc.start() + + def set_event_loop_policy(): if sys.platform.lower().startswith('win'): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -51,6 +56,7 @@ def __init__(self, object_cls : typing.Type[Thing], args : typing.Tuple = tuple( self.kwargs = kwargs + RemoteObject = Thing # reading convenience class EventLoop(RemoteObject): @@ -108,7 +114,7 @@ def __init__(self, *, def __post_init__(self): super().__post_init__() self.logger.info("Event loop with name '{}' can be started using EventLoop.run().".format(self.instance_name)) - return + # example of overloading @remote_method() @@ -117,6 +123,8 @@ def exit(self): Stops the event loop and all its things. Generally, this leads to exiting the program unless some code follows the ``run()`` method. """ + for thing in self.things: + thing.exit() raise BreakAllLoops @@ -221,11 +229,14 @@ def run_external_message_listener(self): """ self.request_listener_loop = self.get_async_loop() rpc_servers = [thing.rpc_server for thing in self.things] + futures = [] for rpc_server in rpc_servers: - self.request_listener_loop.call_soon(lambda : asyncio.create_task(rpc_server.poll())) - self.request_listener_loop.call_soon(lambda : asyncio.create_task(rpc_server.tunnel_message_to_things())) + futures.append(rpc_server.poll()) + futures.append(rpc_server.tunnel_message_to_things()) self.logger.info("starting external message listener thread") - self.request_listener_loop.run_forever() + self.request_listener_loop.run_until_complete(asyncio.gather(*futures)) + pending_tasks = asyncio.all_tasks(self.request_listener_loop) + self.request_listener_loop.run_until_complete(asyncio.gather(*pending_tasks)) self.logger.info("exiting external listener event loop {}".format(self.instance_name)) self.request_listener_loop.close() @@ -239,10 +250,8 @@ def run_things_executor(self, things): thing_executor_loop = self.get_async_loop() self.logger.info(f"starting thing executor loop in thread {threading.get_ident()} for {[obj.instance_name for obj in things]}") thing_executor_loop.run_until_complete( - asyncio.gather( - *[self.run_single_target(instance) - for instance in things] - )) + asyncio.gather(*[self.run_single_target(instance) for instance in things]) + ) self.logger.info(f"exiting event loop in thread {threading.get_ident()}") thing_executor_loop.close() @@ -315,12 +324,23 @@ async def execute_once(cls, instance_name : str, instance : Thing, instruction_s instance.state_machine.current_state in resource.state): # Note that because we actually find the resource within __prepare_instance__, its already bound # and we dont have to separately bind it. + if resource.schema_validator is not None: + resource.schema_validator.validate(arguments) + func = resource.obj args = arguments.pop('__args__', tuple()) if resource.iscoroutine: - return await func(*args, **arguments) + if resource.isparameterized: + if len(args) > 0: + raise RuntimeError("parameterized functions cannot have positional arguments") + return await func(resource.bound_obj, *args, **arguments) + return await func(*args, **arguments) # arguments then become kwargs else: - return func(*args, **arguments) + if resource.isparameterized: + if len(args) > 0: + raise RuntimeError("parameterized functions cannot have positional arguments") + return func(resource.bound_obj, *args, **arguments) + return func(*args, **arguments) # arguments then become kwargs else: raise StateMachineError("Thing '{}' is in '{}' state, however command can be executed only in '{}' state".format( instance_name, instance.state, resource.state)) @@ -339,7 +359,9 @@ async def execute_once(cls, instance_name : str, instance : Thing, instruction_s elif action == "read": return prop.__get__(owner_inst, type(owner_inst)) elif action == "delete": - return prop.deleter() # this may not be correct yet + if prop.fdel is not None: + return prop.fdel() # this may not be correct yet + raise NotImplementedError("This property does not support deletion") raise NotImplementedError("Unimplemented execution path for Thing {} for instruction {}".format(instance_name, instruction_str)) diff --git a/hololinked/server/events.py b/hololinked/server/events.py index 5710ed4..7c73c53 100644 --- a/hololinked/server/events.py +++ b/hololinked/server/events.py @@ -1,10 +1,14 @@ import typing -import threading +import threading +import jsonschema -from ..param import Parameterized +from ..param.parameterized import Parameterized, ParameterizedMetaclass +from .constants import JSON +from .utils import pep8_to_URL_path +from .config import global_config from .zmq_message_brokers import EventPublisher -from .data_classes import ServerSentEvent - +from .dataklasses import ServerSentEvent +from .security_definitions import BaseSecurityDefinition class Event: @@ -19,27 +23,69 @@ class Event: name of the event, specified name may contain dashes and can be used on client side to subscribe to this event. URL_path: str URL path of the event if a HTTP server is used. only GET HTTP methods are supported. + doc: str + docstring for the event + schema: JSON + schema of the event, if the event is JSON complaint. HTTP clients can validate the data with this schema. There + is no validation on server side. """ + # security: Any + # security necessary to access this event. + + __slots__ = ['friendly_name', '_internal_name', '_obj_name', + 'doc', 'schema', 'URL_path', 'security', 'label', 'owner'] + + + def __init__(self, friendly_name : str, URL_path : typing.Optional[str] = None, doc : typing.Optional[str] = None, + schema : typing.Optional[JSON] = None, # security : typing.Optional[BaseSecurityDefinition] = None, + label : typing.Optional[str] = None) -> None: + self.friendly_name = friendly_name + self.doc = doc + if global_config.validate_schemas and schema: + jsonschema.Draft7Validator.check_schema(schema) + self.schema = schema + self.URL_path = URL_path or f'/{pep8_to_URL_path(friendly_name)}' + # self.security = security + self.label = label + + + def __set_name__(self, owner : ParameterizedMetaclass, name : str) -> None: + self._internal_name = f"{pep8_to_URL_path(name)}-dispatcher" + self._obj_name = name + self.owner = owner + + def __get__(self, obj : ParameterizedMetaclass, objtype : typing.Optional[type] = None) -> "EventDispatcher": + try: + return obj.__dict__[self._internal_name] + except KeyError: + raise AttributeError("Event object not yet initialized, please dont access now." + + " Access after Thing is running.") + + def __set__(self, obj : Parameterized, value : typing.Any) -> None: + if isinstance(value, EventDispatcher): + if not obj.__dict__.get(self._internal_name, None): + value._remote_info.name = self.friendly_name + value._remote_info.obj_name = self._obj_name + value._owner_inst = obj + obj.__dict__[self._internal_name] = value + else: + raise AttributeError(f"Event object already assigned for {self._obj_name}. Cannot reassign.") + # may be allowing to reassign is not a bad idea + else: + raise TypeError(f"Supply EventDispatcher object to event {self._obj_name}, not type {type(value)}.") - def __init__(self, name : str, URL_path : typing.Optional[str] = None) -> None: - self.name = name - # self.name_bytes = bytes(name, encoding = 'utf-8') - if URL_path is not None and not URL_path.startswith('/'): - raise ValueError(f"URL_path should start with '/', please add '/' before '{URL_path}'") - self.URL_path = URL_path or '/' + name - self._unique_identifier = None # type: typing.Optional[str] - self._owner = None # type: typing.Optional[Parameterized] - self._remote_info = None # type: typing.Optional[ServerSentEvent] + +class EventDispatcher: + """ + The actual worker which pushes the event. The separation is necessary between ``Event`` and + ``EventDispatcher`` to allow class level definitions of the ``Event`` + """ + def __init__(self, unique_identifier : str) -> None: + self._unique_identifier = bytes(unique_identifier, encoding='utf-8') self._publisher = None - # above two attributes are not really optional, they are set later. + self._remote_info = ServerSentEvent(unique_identifier=unique_identifier) + self._owner_inst = None - @property - def owner(self): - """ - Event owning ``Thing`` object. - """ - return self._owner - @property def publisher(self) -> "EventPublisher": """ @@ -66,14 +112,14 @@ def push(self, data : typing.Any = None, *, serialize : bool = True, **kwargs) - serialize: bool, default True serialize the payload before pushing, set to False when supplying raw bytes **kwargs: - rpc_clients: bool, default True + zmq_clients: bool, default True pushes event to RPC clients, irrelevant if ``Thing`` uses only one type of serializer (refer to - difference between rpc_serializer and json_serializer). + difference between zmq_serializer and http_serializer). http_clients: bool, default True pushed event to HTTP clients, irrelevant if ``Thing`` uses only one type of serializer (refer to - difference between rpc_serializer and json_serializer). + difference between zmq_serializer and http_serializer). """ - self.publisher.publish(self._unique_identifier, data, rpc_clients=kwargs.get('rpc_clients', True), + self.publisher.publish(self._unique_identifier, data, zmq_clients=kwargs.get('zmq_clients', True), http_clients=kwargs.get('http_clients', True), serialize=serialize) diff --git a/hololinked/server/handlers.py b/hololinked/server/handlers.py index 9caa22f..b333524 100644 --- a/hololinked/server/handlers.py +++ b/hololinked/server/handlers.py @@ -5,9 +5,11 @@ from tornado.web import RequestHandler, StaticFileHandler from tornado.iostream import StreamClosedError -from .data_classes import HTTPResource, ServerSentEvent + +from .dataklasses import HTTPResource, ServerSentEvent from .utils import * from .zmq_message_brokers import AsyncEventConsumer, EventConsumer +from .schema_validators import BaseSchemaValidator class BaseHandler(RequestHandler): @@ -15,7 +17,8 @@ class BaseHandler(RequestHandler): Base request handler for RPC operations """ - def initialize(self, resource : typing.Union[HTTPResource, ServerSentEvent], owner = None) -> None: + def initialize(self, resource : typing.Union[HTTPResource, ServerSentEvent], validator : BaseSchemaValidator, + owner = None) -> None: """ Parameters ---------- @@ -27,6 +30,7 @@ def initialize(self, resource : typing.Union[HTTPResource, ServerSentEvent], own from .HTTPServer import HTTPServer assert isinstance(owner, HTTPServer) self.resource = resource + self.schema_validator = validator self.owner = owner self.zmq_client_pool = self.owner.zmq_client_pool self.serializer = self.owner.serializer @@ -171,6 +175,8 @@ async def handle_through_thing(self, http_method : str) -> None: reply = None try: arguments, context, timeout = self.get_execution_parameters() + if self.schema_validator is not None: + self.schema_validator.validate(arguments) reply = await self.zmq_client_pool.async_execute( instance_name=self.resource.instance_name, instruction=self.resource.instructions.__dict__[http_method], @@ -264,7 +270,7 @@ async def handle_datastream(self) -> None: # fashion as HTTP server should be running purely sync(or normal) python method. event_consumer = event_consumer_cls(self.resource.unique_identifier, self.resource.socket_address, identity=f"{self.resource.unique_identifier}|HTTPEvent|{uuid.uuid4()}", - logger=self.logger, json_serializer=self.serializer, + logger=self.logger, http_serializer=self.serializer, context=self.owner._zmq_event_context if self.resource.socket_address.startswith('inproc') else None) event_loop = asyncio.get_event_loop() data_header = b'data: %s\n\n' @@ -310,7 +316,7 @@ async def handle_datastream(self) -> None: try: event_consumer = AsyncEventConsumer(self.resource.unique_identifier, self.resource.socket_address, f"{self.resource.unique_identifier}|HTTPEvent|{uuid.uuid4()}", - json_serializer=self.serializer, logger=self.logger, + http_serializer=self.serializer, logger=self.logger, context=self.owner._zmq_event_context if self.resource.socket_address.startswith('inproc') else None) self.set_header("Content-Type", "application/x-mpegURL") self.write("#EXTM3U\n") @@ -395,4 +401,24 @@ async def options(self): self.finish() +class StopHandler(BaseHandler): + """Stops the tornado HTTP server""" + def initialize(self, owner = None) -> None: + from .HTTPServer import HTTPServer + assert isinstance(owner, HTTPServer) + self.owner = owner + self.allowed_clients = self.owner.allowed_clients + + async def post(self): + if not self.has_access_control: + self.set_status(401, 'forbidden') + else: + try: + # Stop the Tornado server + asyncio.get_event_loop().call_soon(lambda : asyncio.create_task(self.owner.stop())) + self.set_status(204, "ok") + self.set_header("Access-Control-Allow-Credentials", "true") + except Exception as ex: + self.set_status(500, str(ex)) + self.finish() \ No newline at end of file diff --git a/hololinked/server/logger.py b/hololinked/server/logger.py index b97de7e..9222032 100644 --- a/hololinked/server/logger.py +++ b/hololinked/server/logger.py @@ -35,6 +35,18 @@ def emit(self, record : logging.LogRecord): }) +log_message_schema = { + "type" : "object", + "properties" : { + "level" : {"type" : "string" }, + "timestamp" : {"type" : "string" }, + "thread_id" : {"type" : "integer"}, + "message" : {"type" : "string"} + }, + "required" : ["level", "timestamp", "thread_id", "message"], + "additionalProperties" : False +} + class RemoteAccessHandler(logging.Handler, RemoteObject): """ @@ -81,10 +93,12 @@ def __init__(self, instance_name : str = 'logger', maxlen : int = 500, stream_in self.set_maxlen(maxlen, **kwargs) self.stream_interval = stream_interval self.diff_logs = [] - self.event = Event('log-events') self._push_events = False self._events_thread = None + events = Event(friendly_name='log-events', URL_path='/events', doc='stream logs', + schema=log_message_schema) + stream_interval = Number(default=1.0, bounds=(0.025, 60.0), crop_to_bounds=True, step=0.05, URL_path='/stream-interval', doc="interval at which logs should be published to a client.") diff --git a/hololinked/server/properties.py b/hololinked/server/properties.py index a0e385f..8bf9a91 100644 --- a/hololinked/server/properties.py +++ b/hololinked/server/properties.py @@ -23,82 +23,60 @@ class String(Property): - """ - A string property with optional regular expression (regex) matching. - """ + """A string property with optional regular expression (regex) matching.""" + + type = 'string' # TD type __slots__ = ['regex'] def __init__(self, default : typing.Optional[str] = "", *, regex : typing.Optional[str] = None, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, class_member : bool = False, + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.regex = regex def validate_and_adapt(self, value : typing.Any) -> str: - self._assert(value, self.regex, self.allow_None) - return value - - @classmethod - def _assert(obj, value : typing.Any, regex : typing.Optional[str] = None, allow_None : bool = False) -> None: - """ - the method that implements the validator - """ if value is None: - if allow_None: + if self.allow_None: return else: - raise_ValueError(f"None not allowed for string type", obj) + raise_ValueError(f"None not allowed for string type", self) if not isinstance(value, str): - raise_TypeError("given value is not string type, but {}.".format(type(value)), obj) - if regex is not None: - match = re.match(regex, value) + raise_TypeError("given value is not string type, but {}.".format(type(value)), self) + if self.regex is not None: + match = re.match(self.regex, value) if match is None or match.group(0) != value: # match should be original string, not some substring - raise_ValueError("given string value {} does not match regex {}.".format(value, regex), obj) - - @classmethod - def isinstance(cls, value : typing.Any, regex : typing.Optional[str] = None, allow_None : bool = False) -> bool: - """ - verify if given value is a string confirming to regex. - - Args: - value (Any): input value - regex (str, None): regex required to match, leave None if unnecessary - allow_None (bool): set True if None is tolerated - - Returns: - bool: True if conformant, else False. Any exceptions due to wrong inputs resulting in TypeError and ValueError - also lead to False - """ - try: - cls._assert(value, regex, allow_None) - return True - except (TypeError, ValueError): - return False + raise_ValueError("given string value {} does not match regex {}.".format(value, self.regex), self) + return value class Bytes(String): """ - A bytes property with a default value and optional regular - expression (regex) matching. + A bytes property with a default value and optional regular expression (regex) matching. Similar to the string property, but instead of type basestring this property only allows objects of type bytes (e.g. b'bytes'). """ - @classmethod - def _assert(obj, value : typing.Any, regex : typing.Optional[bytes] = None, allow_None : bool = False) -> None: + + def validate_and_adapt(self, value : typing.Any) -> bytes: """ verify if given value is a bytes confirming to regex. @@ -112,69 +90,87 @@ def _assert(obj, value : typing.Any, regex : typing.Optional[bytes] = None, allo ValueError: if regex does not match """ if value is None: - if allow_None: + if self.allow_None: return else: - raise_ValueError(f"None not allowed for string type", obj) + raise_ValueError(f"None not allowed for string type", self) if not isinstance(value, bytes): - raise_TypeError("given value is not bytes type, but {}.".format(type(value)), obj) - if regex is not None: - match = re.match(regex, value) + raise_TypeError("given value is not bytes type, but {}.".format(type(value)), self) + if self.regex is not None: + match = re.match(self.regex, value) if match is None or match.group(0) != value: # match should be original string, not some substring - raise_ValueError("given bytes value {} does not match regex {}.".format(value, regex), obj) - + raise_ValueError("given bytes value {} does not match regex {}.".format(value, self.regex), self) + return value class IPAddress(Property): + """String that allows only IP address""" + + type = 'string' # TD type __slots__ = ['allow_localhost', 'allow_ipv4', 'allow_ipv6'] def __init__(self, default : typing.Optional[str] = "0.0.0.0", *, allow_ipv4 : bool = True, allow_ipv6 : bool = True, allow_localhost : bool = True, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - allow_None : bool = False, per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.allow_localhost = allow_localhost self.allow_ipv4 = allow_ipv4 self.allow_ipv6 = allow_ipv6 def validate_and_adapt(self, value: typing.Any) -> str: - self._assert(value, self.allow_ipv4, self.allow_ipv6, self.allow_localhost, self.allow_None) - return value - - @classmethod - def _assert(obj, value : typing.Any, allow_ipv4 : bool = True, allow_ipv6 : bool = True, - allow_localhost : bool = True, allow_None : bool = False) -> None: - if value is None and allow_None: + if value is None and self.allow_None: return if not isinstance(value, str): - raise_TypeError('given value for IP address not a string, but type {}'.format(type(value)), obj) - if allow_localhost and value == 'localhost': + raise_TypeError('given value for IP address not a string, but type {}'.format(type(value)), self) + if self.allow_localhost and value == 'localhost': return - if not ((allow_ipv4 and (obj.isipv4(value) or obj.isipv4cidr(value))) - or (allow_ipv6 and (obj.isipv6(value) or obj.isipv6cidr(value)))): - raise_ValueError("Given value {} is not a valid IP address.".format(value), obj) - - @classmethod - def isinstance(obj, value : typing.Any, allow_ipv4 : bool = True, allow_ipv6 : bool = True , - allow_localhost : bool = True, allow_None : bool = False) -> bool: - try: - obj._assert(value, allow_ipv4, allow_ipv6, allow_localhost, allow_None) - return True - except (TypeError, ValueError): - return False - + if not ((self.allow_ipv4 and (self.isipv4(value) or self.isipv4cidr(value))) + or (self.allow_ipv6 and (self.isipv6(value) or self.isipv6cidr(value)))): + raise_ValueError("Given value {} is not a valid IP address.".format(value), self) + return value + + """ + The MIT License (MIT) + + Copyright (c) 2013 - 2024 Konsta Vesterinen + + 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. + """ + @classmethod def isipv4(obj, value : str) -> bool: """ @@ -325,57 +321,61 @@ class Number(Property): """ A numeric property with a default value and optional bounds. - There are two types of bounds: ``bounds`` and - ``softbounds``. ``bounds`` are hard bounds: the property must - have a value within the specified range. The default bounds are - (None,None), meaning there are actually no hard bounds. One or - both bounds can be set by specifying a value - (e.g. bounds=(None,10) means there is no lower bound, and an upper + There are two types of bounds: ``bounds`` and ``softbounds``. + ``bounds`` are hard bounds: the property must ave a value within + the specified range. ``softbounds`` are present to indicate the + typical range of the property, but are not enforced. Setting the + soft bounds allows, for instance, a GUI to know what values to display on + sliders for the Number. + + The default bounds (hard-bounds) are (None, None), meaning there are + actually no hard bounds. One or both bounds can be set by specifying a value + (e.g. bounds=(None, 10) means there is no lower bound, and an upper bound of 10). Bounds are inclusive by default, but exclusivity - can be specified for each bound by setting inclusive_bounds - (e.g. inclusive_bounds=(True,False) specifies an exclusive upper - bound). + can be specified for each bound by setting ``inclusive_bounds`` + (e.g. inclusive_bounds=(True, False) specifies an exclusive upper bound). - Using a default value outside the hard - bounds, or one that is not numeric, results in an exception. + Using a default value outside the hard bounds, or one that is not numeric, + results in an exception. - As a special case, if allow_None=True (which is true by default if - the property has a default of None when declared) then a value - of None is also allowed. + As a special case, if ``allow_None=True`` then a value of None is also allowed. A separate function set_in_bounds() is provided that will silently crop the given value into the legal range, for use - in, for instance, a GUI. - - ``softbounds`` are present to indicate the typical range of - the property, but are not enforced. Setting the soft bounds - allows, for instance, a GUI to know what values to display on - sliders for the Number. + in, for instance, a GUI. Example of creating a Number:: - AB = Number(default=0.5, bounds=(None,10), softbounds=(0,1), doc='Distance from A to B.') - + AB = Number(default=0.5, bounds=(None, 10), softbounds=(0, 1), + doc='Distance from A to B.') """ type = 'number' - __slots__ = ['bounds', 'inclusive_bounds', 'crop_to_bounds', 'dtype', 'step'] + __slots__ = ['bounds', 'soft_bounds', 'inclusive_bounds', 'crop_to_bounds', 'dtype', 'step'] def __init__(self, default : typing.Optional[typing.Union[float, int]] = 0.0, *, bounds : typing.Optional[typing.Tuple] = None, crop_to_bounds : bool = False, inclusive_bounds : typing.Tuple = (True,True), step : typing.Any = None, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, soft_bounds : typing.Optional[typing.Tuple] = None, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, - class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.bounds = bounds + self.soft_bounds = soft_bounds self.crop_to_bounds = crop_to_bounds self.inclusive_bounds = inclusive_bounds self.dtype = (float, int) @@ -386,7 +386,7 @@ def set_in_bounds(self, obj : typing.Union[Parameterized, typing.Any], value : t Set to the given value, but cropped to be within the legal bounds. See crop_to_bounds for details on how cropping is done. """ - self._assert(value, self.dtype, None, (False, False), self.allow_None) + value = self.validate_and_adapt(value) bounded_value = self._crop_to_bounds(value) super().__set__(obj, bounded_value) @@ -422,43 +422,37 @@ def _crop_to_bounds(self, value : typing.Union[int, float]) -> typing.Union[int, return value def validate_and_adapt(self, value: typing.Any) -> typing.Union[int, float]: - self._assert(value, self.dtype, None if self.crop_to_bounds else self.bounds, - self.inclusive_bounds, self.allow_None) - if self.crop_to_bounds and self.bounds and value is not None: - return self._crop_to_bounds(value) - return value - - @classmethod - def _assert(obj, value, dtype : typing.Tuple, bounds : typing.Optional[typing.Tuple] = None, - inclusive_bounds : typing.Tuple[bool, bool] = (True, True), allow_None : bool = False): - if allow_None and value is None: + if self.allow_None and value is None: return - if dtype is None: - if not obj.isnumber(value): + if self.dtype is None: + if not self.isnumber(value): raise_TypeError("given value not of number type, but type {}.".format(type(value)), - obj) - elif not isinstance(value, dtype): - raise_TypeError("given value not of type {}, but type {}.".format(dtype, type(value)), obj) - if bounds: - vmin, vmax = bounds - incmin, incmax = inclusive_bounds + self) + elif not isinstance(value, self.dtype): + raise_TypeError("given value not of type {}, but type {}.".format(self.dtype, type(value)), self) + if self.bounds: + vmin, vmax = self.bounds + incmin, incmax = self.inclusive_bounds if vmax is not None: if incmax is True: if not value <= vmax: - raise_ValueError("given value must be at most {}, not {}.".format(vmax, value), obj) + raise_ValueError("given value must be at most {}, not {}.".format(vmax, value), self) else: if not value < vmax: - raise_ValueError("Property must be less than {}, not {}.".format(vmax, value), obj) + raise_ValueError("Property must be less than {}, not {}.".format(vmax, value), self) if vmin is not None: if incmin is True: if not value >= vmin: - raise_ValueError("Property must be at least {}, not {}.".format(vmin, value), obj) + raise_ValueError("Property must be at least {}, not {}.".format(vmin, value), self) else: if not value > vmin: - raise_ValueError("Property must be greater than {}, not {}.".format(vmin, value), obj) + raise_ValueError("Property must be greater than {}, not {}.".format(vmin, value), self) return value - + if self.crop_to_bounds and self.bounds and value is not None: + return self._crop_to_bounds(value) + return value + def _validate_step(self, value : typing.Any) -> None: if value is not None: if self.dtype: @@ -471,15 +465,6 @@ def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> No if slot == 'step': self._validate_step(value) return super()._post_slot_set(slot, old, value) - - @classmethod - def isinstance(obj, value, dtype : typing.Tuple, bounds : typing.Optional[typing.Tuple] = None, - inclusive_bounds : typing.Tuple[bool, bool] = (True, True), allow_None : bool = False): - try: - obj._assert(value, dtype, bounds, inclusive_bounds, allow_None) - return True - except (ValueError, TypeError): - return False @classmethod def isnumber(cls, value : typing.Any) -> bool: @@ -494,25 +479,33 @@ def isnumber(cls, value : typing.Any) -> bool: class Integer(Number): + """Numeric Property required to be an integer""" - """Numeric Property required to be an Integer""" + type = 'integer' def __init__(self, default : typing.Optional[int] = 0, *, bounds : typing.Optional[typing.Tuple] = None, crop_to_bounds : bool = False, inclusive_bounds : typing.Tuple = (True,True), step : typing.Any = None, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, class_member : bool = False, - fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, soft_bounds : typing.Optional[typing.Tuple] = None, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, bounds=bounds, crop_to_bounds=crop_to_bounds, inclusive_bounds=inclusive_bounds, - doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote, step=step) - self.dtype = (int,) + soft_bounds=soft_bounds, step=step, doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) + self.dtype = (int, ) def _validate_step(self, step : int): if step is not None and not isinstance(step, int): @@ -521,21 +514,28 @@ def _validate_step(self, step : int): class Boolean(Property): - """Binary or tristate Boolean Property.""" + """Binary or tristate boolean Property.""" def __init__(self, default : typing.Optional[bool] = False, *, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, - class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) def validate_and_adapt(self, value : typing.Any) -> bool: if not isinstance(value, bool): @@ -550,66 +550,57 @@ class Iterable(Property): __slots__ = ['bounds', 'length', 'item_type', 'dtype'] def __init__(self, default : typing.Any, *, bounds : typing.Optional[typing.Tuple[int, int]] = None, - length : typing.Optional[int] = None, item_type : typing.Optional[typing.Tuple] = None, deepcopy_default : bool = False, - allow_None : bool = False, doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, class_member : bool = False, - fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - """ - Initialize a tuple property with a fixed length (number of - elements). The length is determined by the initial default - value, if any, and must be supplied explicitly otherwise. The - length is not allowed to change after instantiation. - """ + length : typing.Optional[int] = None, item_type : typing.Optional[typing.Tuple] = None, + doc : typing.Optional[str] = None, constant : bool = False, deepcopy_default : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, - class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.bounds = bounds self.length = length self.item_type = item_type self.dtype = (list, tuple) + """ + Initialize a tuple property with a fixed length (number of + elements). The length is determined by the initial default + value, if any, and must be supplied explicitly otherwise. The + length is not allowed to change after instantiation. + """ def validate_and_adapt(self, value: typing.Any) -> typing.Union[typing.List, typing.Tuple]: - self._assert(value, self.bounds, self.length, self.dtype, self.item_type, self.allow_None) - return value - - @classmethod - def _assert(obj, value : typing.Any, bounds : typing.Optional[typing.Tuple[int, int]] = None, - length : typing.Optional[int] = None, dtype : typing.Union[type, typing.Tuple] = (list, tuple), - item_type : typing.Any = None, allow_None : bool = False) -> None: - if value is None and allow_None: + if value is None and self.allow_None: return - if not isinstance(value, dtype): - raise_ValueError("given value not of iterable type {}, but {}.".format(dtype, type(value)), obj) - if bounds is not None: - if not (len(value) >= bounds[0] and len(value) <= bounds[1]): + if not isinstance(value, self.dtype): + raise_ValueError("given value not of iterable type {}, but {}.".format(self.dtype, type(value)), self) + if self.bounds is not None: + if not (len(value) >= self.bounds[0] and len(value) <= self.bounds[1]): raise_ValueError("given iterable is not of the correct length ({} instead of between {} and {}).".format( - len(value), 0 if not bounds[0] else bounds[0], bounds[1]), obj) - elif length is not None and len(value) != length: - raise_ValueError("given iterable is not of correct length ({} instead of {})".format(len(value), length), - obj) - if item_type is not None: + len(value), 0 if not self.bounds[0] else self.bounds[0], self.bounds[1]), self) + elif self.length is not None and len(value) != self.length: + raise_ValueError("given iterable is not of correct length ({} instead of {})".format(len(value), self.length), + self) + if self.item_type is not None: for val in value: - if not isinstance(val, item_type): + if not isinstance(val, self.item_type): raise_TypeError("not all elements of given iterable of item type {}, found object of type {}".format( - item_type, type(val)), obj) - - @classmethod - def isinstance(obj, value : typing.Any, bounds : typing.Optional[typing.Tuple[int, int]], - length : typing.Optional[int] = None, dtype : typing.Union[type, typing.Tuple] = (list, tuple), - item_type : typing.Any = None, allow_None : bool = False) -> bool: - try: - obj._assert(value, bounds, length, dtype, item_type, allow_None) - return True - except (ValueError, TypeError): - return False - - + self.item_type, type(val)), self) + return value + + class Tuple(Iterable): @@ -618,27 +609,33 @@ class Tuple(Iterable): def __init__(self, default : typing.Any, *, bounds : typing.Optional[typing.Tuple[int, int]] = None, length: typing.Optional[int] = None, item_type : typing.Optional[typing.Tuple] = None, accept_list : bool = False, deepcopy_default : bool = False, - allow_None : bool = False, doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, class_member : bool = False, - fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - super().__init__(default=default, bounds=bounds, length=length, item_type=item_type, doc=doc, constant=constant, - readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: + super().__init__(default=default, bounds=bounds, length=length, item_type=item_type, + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.accept_list = accept_list self.dtype = (tuple,) # re-assigned def validate_and_adapt(self, value: typing.Any) -> typing.Tuple: if self.accept_list and isinstance(value, list): value = tuple(value) - self._assert(value, self.bounds, self.length, self.dtype, self.item_type, self.allow_None) - return value + return super().validate_and_adapt(value) @classmethod def serialize(cls, value): @@ -672,28 +669,34 @@ class List(Iterable): def __init__(self, default: typing.Any, *, bounds : typing.Optional[typing.Tuple[int, int]] = None, length : typing.Optional[int] = None, item_type : typing.Optional[typing.Tuple] = None, accept_tuple : bool = False, deepcopy_default : bool = False, - allow_None : bool = False, doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, - fset : typing.Optional[typing.Callable] = None, fdel : typing.Optional[typing.Callable] = None, - precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, bounds=bounds, length=length, item_type=item_type, - doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, - per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, - class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.accept_tuple = accept_tuple self.dtype = list + def validate_and_adapt(self, value: typing.Any) -> typing.Tuple: if self.accept_tuple and isinstance(value, tuple): value = list(value) - self._assert(value, self.bounds, self.length, self.dtype, self.item_type, self.allow_None) - return value + return super().validate_and_adapt(value) @@ -732,18 +735,25 @@ class Composite(Property): __slots__ = ['attribs'] def __init__(self, attribs : typing.List[typing.Union[str, Property]], *, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - super().__init__(None, doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, - per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, - class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, label : typing.Optional[str] = None, URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: + kwargs.pop('allow_None') + super().__init__(None, doc=doc, constant=constant, readonly=readonly, allow_None=True, + label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.attribs = [] if attribs is not None: for attrib in attribs: @@ -752,7 +762,7 @@ def __init__(self, attribs : typing.List[typing.Union[str, Property]], *, else: self.attribs.append(attrib) - def __get__(self, obj, objtype) -> typing.List[typing.Any]: + def __get__(self, obj : Parameterized, objtype : typing.Type[Parameterized]) -> typing.List[typing.Any]: """ Return the values of all the attribs, as a list. """ @@ -817,19 +827,25 @@ class Selector(SelectorBase): # Selector is usually used to allow selection from a list of # existing objects, therefore instantiate is False by default. def __init__(self, *, objects : typing.List[typing.Any], default : typing.Any, empty_default : bool = False, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, - fset : typing.Optional[typing.Callable] = None, fdel : typing.Optional[typing.Callable] = None, - precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, - class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) if objects is None: objects = [] autodefault = None @@ -879,18 +895,25 @@ class ClassSelector(SelectorBase): __slots__ = ['class_', 'isinstance'] def __init__(self, *, class_ , default : typing.Any, isinstance : bool = True, deepcopy_default : bool = False, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, class_member : bool = False, - fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, - class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.class_ = class_ self.isinstance = isinstance @@ -908,9 +931,14 @@ def validate_and_adapt(self, value): raise_ValueError("{} property {} value must be an instance of {}, not {}.".format( self.__class__.__name__, self.name, self._get_class_name(), value), self) else: - if not issubclass(value, self.class_): - raise_ValueError("{} property {} must be a subclass of {}, not {}.".format( - self.__class__.__name__, self.name, self._get_class_name(), value.__name__), self) + try: + if not issubclass(value, self.class_): + raise_ValueError("{} property {} must be a subclass of {}, not {}.".format( + self.__class__.__name__, self.name, self._get_class_name(), value.__name__), self) + except TypeError as ex: + if str(ex).startswith("issubclass() arg 1 must be a class"): + raise_ValueError("Value must be a class, not an instance.", self) + raise ex from None # raise other type errors anyway return value @property @@ -947,19 +975,26 @@ class TupleSelector(Selector): __slots__ = ['accept_list'] def __init__(self, *, objects : typing.List, default : typing.Any, accept_list : bool = True, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - super().__init__(objects=objects, default=default, empty_default=True, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: + super().__init__(objects=objects, default=default, empty_default=True, + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.accept_list = accept_list def validate_and_adapt(self, value : typing.Any): @@ -1010,19 +1045,25 @@ class Path(Property): __slots__ = ['search_paths'] def __init__(self, default : typing.Any = '', *, search_paths : typing.Optional[str] = None, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - super().__init__(default=default, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: + super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) if isinstance(search_paths, str): self.search_paths = [search_paths] elif isinstance(search_paths, list): @@ -1118,19 +1159,26 @@ class FileSelector(Selector): __slots__ = ['path'] def __init__(self, default : typing.Any, *, objects : typing.List, path : str = "", - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - super().__init__(default=default, objects=objects, empty_default=True, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: + super().__init__(default=default, objects=objects, empty_default=True, + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.path = path # update is automatically called def _post_slot_set(self, slot: str, old : typing.Any, value : typing.Any) -> None: @@ -1157,19 +1205,26 @@ class MultiFileSelector(FileSelector): __slots__ = ['path'] def __init__(self, default : typing.Any, *, path : str = "", - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - label : typing.Optional[str] = None, per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - super().__init__(default=default, objects=None, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, - fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: + super().__init__(default=default, objects=None, doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) + self.path = path def update(self): self.objects = sorted(glob.glob(self.path)) @@ -1186,20 +1241,26 @@ class Date(Number): def __init__(self, default, *, bounds : typing.Union[typing.Tuple, None] = None, crop_to_bounds : bool = False, inclusive_bounds : typing.Tuple = (True,True), step : typing.Any = None, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, bounds=bounds, crop_to_bounds=crop_to_bounds, - inclusive_bounds=inclusive_bounds, step=step, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + inclusive_bounds=inclusive_bounds, step=step, doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.dtype = dt_types def _validate_step(self, val): @@ -1229,20 +1290,26 @@ class CalendarDate(Number): def __init__(self, default, *, bounds : typing.Union[typing.Tuple, None] = None, crop_to_bounds : bool = False, inclusive_bounds : typing.Tuple = (True,True), step : typing.Any = None, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: super().__init__(default=default, bounds=bounds, crop_to_bounds=crop_to_bounds, - inclusive_bounds=inclusive_bounds, step=step, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + inclusive_bounds=inclusive_bounds, step=step, doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.dtype = dt.date def _validate_step(self, step): @@ -1308,20 +1375,26 @@ class CSS3Color(Property): __slots__ = ['allow_named'] - def __init__(self, default, *, allow_named : bool = True, doc : typing.Optional[str] = None, constant : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - readonly : bool = False, allow_None : bool = False, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: - super().__init__(default=default, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + def __init__(self, default, *, allow_named : bool = True, + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: + super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.allow_named = allow_named def validate_and_adapt(self, value : typing.Any): @@ -1336,7 +1409,8 @@ def validate_and_adapt(self, value : typing.Any): if not is_hex: raise ValueError("Color '%s' only takes RGB hex codes " "or named colors, received '%s'." % (self.name, value)) - + return value + class Range(Tuple): @@ -1346,27 +1420,33 @@ class Range(Tuple): __slots__ = ['bounds', 'inclusive_bounds', 'softbounds', 'step'] - def __init__(self, default : typing.Optional[typing.Tuple] = None, *, bounds: typing.Optional[typing.Tuple[int, int]] = None, - length : typing.Optional[int] = None, item_type : typing.Optional[typing.Tuple] = None, - softbounds=None, inclusive_bounds=(True,True), step=None, + def __init__(self, default : typing.Optional[typing.Tuple] = None, *, + bounds: typing.Optional[typing.Tuple[int, int]] = None, length : typing.Optional[int] = None, + item_type : typing.Optional[typing.Tuple] = None, softbounds=None, inclusive_bounds=(True,True), step=None, doc : typing.Optional[str] = None, constant : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, - per_instance_descriptor : bool = False, deepcopy_default : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: self.inclusive_bounds = inclusive_bounds self.softbounds = softbounds self.step = step - super().__init__(default=default, bounds=bounds, item_type=item_type, length=length, doc=doc, - constant=constant, readonly=readonly, allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, - deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, - precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) - + super().__init__(default=default, bounds=bounds, item_type=item_type, length=length, + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) + def validate_and_adapt(self, value : typing.Any) -> typing.Tuple: raise NotImplementedError("Range validation not implemented") super()._validate(val) @@ -1498,22 +1578,28 @@ class TypedList(ClassSelector): def __init__(self, default : typing.Optional[typing.List[typing.Any]] = None, *, item_type : typing.Any = None, deepcopy_default : bool = True, allow_None : bool = True, bounds : tuple = (0,None), - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, class_member : bool = False, - fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, label : typing.Optional[str] = None,URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: if default is not None: default = TypeConstrainedList(default=default, item_type=item_type, bounds=bounds, constant=constant, - skip_validate=False) # type: ignore - super().__init__(class_ = TypeConstrainedList, default=default, isinstance=True, deepcopy_default=deepcopy_default, - doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, - per_instance_descriptor=per_instance_descriptor, class_member=class_member, fget=fget, fset=fset, - fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + skip_validate=False) + super().__init__(class_=TypeConstrainedList, default=default, isinstance=True, + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) self.item_type = item_type self.bounds = bounds @@ -1540,26 +1626,32 @@ class TypedDict(ClassSelector): __slots__ = ['key_type', 'item_type', 'bounds'] def __init__(self, default : typing.Optional[typing.Dict] = None, *, key_type : typing.Any = None, - item_type : typing.Any = None, deepcopy_default : bool = True, allow_None : bool = True, - bounds : tuple = (0, None), doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, - fset : typing.Optional[typing.Callable] = None, fdel : typing.Optional[typing.Callable] = None, - precedence : typing.Optional[float] = None) -> None: + item_type : typing.Any = None, deepcopy_default : bool = True, allow_None : bool = True, + bounds : tuple = (0, None), doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, label : typing.Optional[str] = None, URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: if default is not None: default = TypeConstrainedDict(default, key_type=key_type, item_type=item_type, bounds=bounds, - constant=constant, skip_validate=False) # type: ignore + constant=constant, skip_validate=False) self.key_type = key_type self.item_type = item_type self.bounds = bounds - super().__init__(class_=TypeConstrainedDict, default=default, isinstance=True, deepcopy_default=deepcopy_default, - doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, fget=fget, fset=fset, fdel=fdel, - per_instance_descriptor=per_instance_descriptor, class_member=class_member, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + super().__init__(class_=TypeConstrainedDict, default=default, isinstance=True, + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) def __set__(self, obj, value): if value is not None: @@ -1581,29 +1673,34 @@ class TypedKeyMappingsDict(ClassSelector): __slots__ = ['type_mapping', 'allow_unspecified_keys', 'bounds'] def __init__(self, default : typing.Optional[typing.Dict[typing.Any, typing.Any]] = None, *, - type_mapping : typing.Dict, - allow_unspecified_keys : bool = True, bounds : tuple = (0, None), - deepcopy_default : bool = True, allow_None : bool = True, - doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, - URL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT), remote : bool = True, - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - per_instance_descriptor : bool = False, class_member : bool = False, - fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, - fdel : typing.Optional[typing.Callable] = None, precedence : typing.Optional[float] = None) -> None: + type_mapping : typing.Dict, allow_unspecified_keys : bool = True, bounds : tuple = (0, None), + deepcopy_default : bool = True, allow_None : bool = True, doc : typing.Optional[str] = None, + constant : bool = False, readonly : bool = False, label : typing.Optional[str] = None, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, + per_instance_descriptor : bool = False, remote : bool = True, + precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None, **kwargs + ) -> None: if default is not None: default = TypedKeyMappingsConstrainedDict(default=default, type_mapping=type_mapping, allow_unspecified_keys=allow_unspecified_keys, bounds=bounds, constant=constant, - skip_validate=False) # type: ignore + skip_validate=False) self.type_mapping = type_mapping self.allow_unspecified_keys = allow_unspecified_keys self.bounds = bounds - super().__init__(class_=TypedKeyMappingsConstrainedDict, default=default, - isinstance=True, deepcopy_default=deepcopy_default, doc=doc, constant=constant, readonly=readonly, - allow_None=allow_None, per_instance_descriptor=per_instance_descriptor, class_member=class_member, - fget=fget, fset=fset, fdel=fdel, precedence=precedence, - URL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist, - db_init=db_init, db_commit=db_commit, remote=remote) + super().__init__(class_=TypedKeyMappingsConstrainedDict, default=default, isinstance=True, + doc=doc, constant=constant, readonly=readonly, + allow_None=allow_None, label=label, URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + observable=observable, remote=remote, fget=fget, fset=fset, fdel=fdel, fcomparator=fcomparator, + metadata=metadata, precedence=precedence, per_instance_descriptor=per_instance_descriptor, + deepcopy_default=deepcopy_default, **kwargs) def __set__(self, obj, value): if value is not None: diff --git a/hololinked/server/property.py b/hololinked/server/property.py index 6f6a6e6..024314b 100644 --- a/hololinked/server/property.py +++ b/hololinked/server/property.py @@ -1,11 +1,13 @@ import typing from types import FunctionType, MethodType from enum import Enum +import warnings -from ..param.parameterized import Parameter, ClassParameters -from .data_classes import RemoteResourceInfoValidator +from ..param.parameterized import Parameter, ClassParameters, Parameterized, ParameterizedMetaclass +from .utils import issubklass, pep8_to_URL_path +from .dataklasses import RemoteResourceInfoValidator from .constants import USE_OBJECT_NAME, HTTP_METHODS -from .events import Event +from .events import Event, EventDispatcher @@ -38,11 +40,11 @@ class Property(Parameter): allowed. URL_path: str, uses object name by default - resource locator under which the attribute is accessible through HTTP. when value is supplied, the variable name + resource locator under which the attribute is accessible through HTTP. When not given, the variable name is used and underscores are replaced with dash http_method: tuple, default ("GET", "PUT", "DELETE") - http methods for read, write, delete respectively + http methods for read, write and delete respectively observable: bool, default False set to True to receive change events. Supply a function if interested to evaluate on what conditions the change @@ -108,8 +110,8 @@ class Property(Parameter): """ - __slots__ = ['db_persist', 'db_init', 'db_commit', 'metadata', '_remote_info', 'observable', - '_observable_event', 'fcomparator'] + __slots__ = ['db_persist', 'db_init', 'db_commit', 'metadata', '_remote_info', + '_observable', '_observable_event_descriptor', 'fcomparator', '_old_value_internal_name'] # RPC only init - no HTTP methods for those who dont like @typing.overload @@ -138,7 +140,8 @@ def __init__(self, default: typing.Any = None, *, doc : typing.Optional[str] = N def __init__(self, default: typing.Any = None, *, doc : typing.Optional[str] = None, constant : bool = False, readonly : bool = False, allow_None : bool = False, URL_path : str = USE_OBJECT_NAME, - http_method : typing.Tuple[typing.Optional[str], typing.Optional[str]] = (HTTP_METHODS.GET, HTTP_METHODS.PUT), + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), observable : bool = False, change_comparator : typing.Optional[typing.Union[FunctionType, MethodType]] = None, state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, db_persist : bool = False, db_init : bool = False, db_commit : bool = False, remote : bool = True, @@ -150,28 +153,31 @@ def __init__(self, default: typing.Any = None, *, doc : typing.Optional[str] = N ) -> None: ... - def __init__(self, default: typing.Any = None, *, doc : typing.Optional[str] = None, constant : bool = False, - readonly : bool = False, allow_None : bool = False, + def __init__(self, default: typing.Any = None, *, + doc : typing.Optional[str] = None, constant : bool = False, + readonly : bool = False, allow_None : bool = False, label : typing.Optional[str] = None, URL_path : str = USE_OBJECT_NAME, - http_method : typing.Tuple[typing.Optional[str], typing.Optional[str]] = (HTTP_METHODS.GET, HTTP_METHODS.PUT), + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]] = + (HTTP_METHODS.GET, HTTP_METHODS.PUT, HTTP_METHODS.DELETE), state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - observable : bool = False, change_comparator : typing.Optional[typing.Union[FunctionType, MethodType]] = None, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, - fset : typing.Optional[typing.Callable] = None, fdel : typing.Optional[typing.Callable] = None, - fcomparator : typing.Optional[typing.Callable] = None, + observable : bool = False, class_member : bool = False, + fget : typing.Optional[typing.Callable] = None, fset : typing.Optional[typing.Callable] = None, + fdel : typing.Optional[typing.Callable] = None, fcomparator : typing.Optional[typing.Callable] = None, deepcopy_default : bool = False, per_instance_descriptor : bool = False, remote : bool = True, precedence : typing.Optional[float] = None, metadata : typing.Optional[typing.Dict] = None ) -> None: super().__init__(default=default, doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, - per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, + label=label, per_instance_descriptor=per_instance_descriptor, deepcopy_default=deepcopy_default, class_member=class_member, fget=fget, fset=fset, fdel=fdel, precedence=precedence) self.db_persist = db_persist self.db_init = db_init self.db_commit = db_commit + self.fcomparator = fcomparator self.metadata = metadata - self.observable = observable - self._observable_event = None + self._observable = observable + self._observable_event_descriptor : Event = None + self._remote_info = None if remote: self._remote_info = RemoteResourceInfoValidator( http_method=http_method, @@ -179,50 +185,73 @@ def __init__(self, default: typing.Any = None, *, doc : typing.Optional[str] = N state=state, isproperty=True ) - else: - self._remote_info = None - self.fcomparator = fcomparator - - def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> None: - if slot == 'owner' and self.owner is not None: - if self._remote_info is not None: - if self._remote_info.URL_path == USE_OBJECT_NAME: - self._remote_info.URL_path = '/' + self.name - elif not self._remote_info.URL_path.startswith('/'): - raise ValueError(f"URL_path should start with '/', please add '/' before '{self._remote_info.URL_path}'") - self._remote_info.obj_name = self.name - if self.observable: - self._observable_event = Event(name=f'observable-{self.name}', - URL_path=f'{self._remote_info.URL_path}/change-event') - # In principle the above could be done when setting name itself however to simplify - # we do it with owner. So we should always remember order of __set_name__ -> 1) attrib_name, - # 2) name and then 3) owner - super()._post_slot_set(slot, old, value) + + + def __set_name__(self, owner: typing.Any, attrib_name: str) -> None: + super().__set_name__(owner, attrib_name) + self._old_value_internal_name = f'{self._internal_name}_old_value' + if self._remote_info is not None: + if self._remote_info.URL_path == USE_OBJECT_NAME: + self._remote_info.URL_path = f'/{pep8_to_URL_path(self.name)}' + elif not self._remote_info.URL_path.startswith('/'): + raise ValueError(f"URL_path should start with '/', please add '/' before '{self._remote_info.URL_path}'") + self._remote_info.obj_name = self.name + if self._observable: + _observable_event_name = f'{self.name}_change_event' + # This is a descriptor object, so we need to set it on the owner class + self._observable_event_descriptor = Event( + friendly_name=_observable_event_name, + URL_path=f'{self._remote_info.URL_path}/change-event', + doc=f"change event for {self.name}" + ) # type: Event + self._observable_event_descriptor.__set_name__(owner, _observable_event_name) + setattr(owner, _observable_event_name, self._observable_event_descriptor) + + + def _push_change_event_if_needed(self, obj, value : typing.Any) -> None: + """ + Pushes change event both on read and write if an event publisher object is available + on the owning Thing. + """ + if self._observable and obj.event_publisher: + event_dispatcher = getattr(obj, self._observable_event_descriptor._obj_name, None) # type: EventDispatcher + old_value = obj.__dict__.get(self._old_value_internal_name, NotImplemented) + obj.__dict__[self._old_value_internal_name] = value + if self.fcomparator: + if issubklass(self.fcomparator): + if not self.fcomparator(self.owner, old_value, value): + return + elif not self.fcomparator(obj, old_value, value): + return + elif not old_value != value: + return + event_dispatcher.push(value) + + + def __get__(self, obj: Parameterized, objtype: ParameterizedMetaclass) -> typing.Any: + read_value = super().__get__(obj, objtype) + self._push_change_event_if_needed(obj, read_value) + return read_value + def _post_value_set(self, obj, value : typing.Any) -> None: if (self.db_persist or self.db_commit) and hasattr(obj, 'db_engine'): - # from .thing import Thing - # assert isinstance(obj, Thing), f"database property {self.name} bound to a non Thing, currently not supported" - # uncomment for type definitions + from .thing import Thing + assert isinstance(obj, Thing), f"database property {self.name} bound to a non Thing, currently not supported" obj.db_engine.set_property(self, value) - if self.observable and self._observable_event is not None: - old_value = obj.__dict__.get(self._internal_name, NotImplemented) - obj.__dict__[f'{self._internal_name}_old_value'] = value - if self.fcomparator: - if self.fcomparator(old_value, value): - self._observable_event.push(value) - elif old_value != value: - self._observable_event.push(value) + self._push_change_event_if_needed(obj, value) return super()._post_value_set(obj, value) + def comparator(self, func : typing.Callable) -> typing.Callable: """ - Register a getter method by using this as a decorator. + Register a comparator method by using this as a decorator to decide when to push + a change event. """ self.fcomparator = func return func - + __property_info__ = [ 'allow_None' , 'class_member', 'db_init', 'db_persist', @@ -298,21 +327,7 @@ def webgui_info(self, for_remote_params : typing.Union[Property, typing.Dict[str info[param.name][field] = state.get(field, None) return info - @property - def visualization_parameters(self): - from ..webdashboard.visualization_parameters import VisualizationParameter - try: - return getattr(self.owner_cls, f'_{self.owner_cls.__name__}_visualization_params') - except AttributeError: - paramdict = super().descriptors - visual_params = {} - for name, desc in paramdict.items(): - if isinstance(desc, VisualizationParameter): - visual_params[name] = desc - setattr(self.owner_cls, f'_{self.owner_cls.__name__}_visualization_params', visual_params) - return getattr(self.owner_cls, f'_{self.owner_cls.__name__}_visualization_params') - - + __all__ = [ Property.__name__ diff --git a/hololinked/server/schema_validators.py b/hololinked/server/schema_validators.py new file mode 100644 index 0000000..7287f52 --- /dev/null +++ b/hololinked/server/schema_validators.py @@ -0,0 +1,109 @@ +import typing +from .constants import JSON + +class JSONSchemaError(Exception): + """ + common error to be raised for JSON schema + validation irrespective of internal validation used + """ + pass + +class JSONValidationError(Exception): + """ + common error to be raised for JSON validation + irrespective of internal validation used + """ + pass + + + +class BaseSchemaValidator: # type definition + """ + Base class for all schema validators. + Serves as a type definition. + """ + def __init__(self, schema : JSON) -> None: + self.schema = schema + + def validate(self, data) -> None: + """ + validate the data against the schema. + """ + raise NotImplementedError("validate method must be implemented by subclass") + + +try: + import fastjsonschema + + class FastJsonSchemaValidator(BaseSchemaValidator): + """ + JSON schema validator according to fast JSON schema. + Useful for performance with dictionary based schema specification + which msgspec has no built in support. Normally, for speed, + one should try to use msgspec's struct concept. + """ + + def __init__(self, schema : JSON) -> None: + super().__init__(schema) + self.validator = fastjsonschema.compile(schema) + + def validate(self, data) -> None: + """validates and raises exception when failed directly to the caller""" + try: + self.validator(data) + except fastjsonschema.JsonSchemaException as ex: + raise JSONSchemaError(str(ex)) from None + + def json(self): + """allows JSON (de-)serializable of the instance itself""" + return self.schema + + def __get_state__(self): + return self.schema + + def __set_state__(self, schema): + return FastJsonSchemaValidator(schema) + +except ImportError as ex: + pass + + + +import jsonschema + +class JsonSchemaValidator(BaseSchemaValidator): + """ + JSON schema validator according to standard python JSON schema. + Somewhat slow, consider msgspec if possible. + """ + + def __init__(self, schema) -> None: + jsonschema.Draft7Validator.check_schema(schema) + super().__init__(schema) + self.validator = jsonschema.Draft7Validator(schema) + + def validate(self, data) -> None: + self.validator.validate(data) + + def json(self): + """allows JSON (de-)serializable of the instance itself""" + return self.schema + + def __get_state__(self): + return self.schema + + def __set_state__(self, schema): + return JsonSchemaValidator(schema) + + + +def _get_validator_from_user_options(option : typing.Optional[str] = None) -> BaseSchemaValidator: + """ + returns a JSON schema validator based on user options + """ + if option == "fastjsonschema": + return FastJsonSchemaValidator + elif option == "jsonschema" or not option: + return JsonSchemaValidator + else: + raise ValueError(f"Unknown JSON schema validator option: {option}") \ No newline at end of file diff --git a/hololinked/server/security_definitions.py b/hololinked/server/security_definitions.py new file mode 100644 index 0000000..c76ff66 --- /dev/null +++ b/hololinked/server/security_definitions.py @@ -0,0 +1,5 @@ + + + +class BaseSecurityDefinition: + """Type shield for all security definitions""" diff --git a/hololinked/server/serializers.py b/hololinked/server/serializers.py index b27a045..664a47e 100644 --- a/hololinked/server/serializers.py +++ b/hololinked/server/serializers.py @@ -177,8 +177,8 @@ def loads(self, data) -> typing.Any: class MsgpackSerializer(BaseSerializer): """ (de)serializer that wraps the msgspec MessagePack serialization protocol, recommended serializer for ZMQ based - high speed applications. Set an instance of this serializer to both ``Thing.rpc_serializer`` and - ``hololinked.client.ObjectProxy``. + high speed applications. Set an instance of this serializer to both ``Thing.zmq_serializer`` and + ``hololinked.client.ObjectProxy``. Unfortunately, MessagePack is currently not supported for HTTP clients. """ def __init__(self) -> None: @@ -195,7 +195,7 @@ def loads(self, value) -> typing.Any: None : JSONSerializer, 'json' : JSONSerializer, 'pickle' : PickleSerializer, - 'msgpack' : MsgpackSerializer, + 'msgpack' : MsgpackSerializer } @@ -231,34 +231,35 @@ def custom_serializer(obj, serpent_serializer, outputstream, indentlevel): raise ValueError("refusing to register replacement for a non-type or the type 'type' itself") serpent.register_class(object_type, custom_serializer) - serializers['serpent'] = SerpentSerializer, + serializers['serpent'] = SerpentSerializer except ImportError: pass def _get_serializer_from_user_given_options( - rpc_serializer : typing.Union[str, BaseSerializer], - json_serializer : typing.Union[str, JSONSerializer] + zmq_serializer : typing.Union[str, BaseSerializer], + http_serializer : typing.Union[str, JSONSerializer] ) -> typing.Tuple[BaseSerializer, JSONSerializer]: """ We give options to specify serializer as a string or an object, """ - if json_serializer in [None, 'json'] or isinstance(json_serializer, JSONSerializer): - json_serializer = json_serializer if isinstance(json_serializer, JSONSerializer) else JSONSerializer() + if http_serializer in [None, 'json'] or isinstance(http_serializer, JSONSerializer): + http_serializer = http_serializer if isinstance(http_serializer, JSONSerializer) else JSONSerializer() else: - raise ValueError("invalid JSON serializer option : {}".format(json_serializer)) - if isinstance(rpc_serializer, BaseSerializer): - rpc_serializer = rpc_serializer - if isinstance(rpc_serializer, PickleSerializer) or rpc_serializer.type == pickle: + raise ValueError("invalid JSON serializer option : {}".format(http_serializer)) + # could also technically be TypeError + if isinstance(zmq_serializer, BaseSerializer): + zmq_serializer = zmq_serializer + if isinstance(zmq_serializer, PickleSerializer) or zmq_serializer.type == pickle: warnings.warn("using pickle serializer which is unsafe, consider another like msgpack.", UserWarning) - elif rpc_serializer == 'json' or rpc_serializer is None: - rpc_serializer = json_serializer - elif isinstance(rpc_serializer, str): - rpc_serializer = serializers.get(rpc_serializer, JSONSerializer)() + elif zmq_serializer == 'json' or zmq_serializer is None: + zmq_serializer = http_serializer + elif isinstance(zmq_serializer, str): + zmq_serializer = serializers.get(zmq_serializer, JSONSerializer)() else: - raise ValueError("invalid rpc serializer option : {}".format(rpc_serializer)) - return rpc_serializer, json_serializer + raise ValueError("invalid rpc serializer option : {}".format(zmq_serializer)) + return zmq_serializer, http_serializer diff --git a/hololinked/server/state_machine.py b/hololinked/server/state_machine.py index ecda160..ffee714 100644 --- a/hololinked/server/state_machine.py +++ b/hololinked/server/state_machine.py @@ -5,7 +5,7 @@ from ..param.parameterized import Parameterized from .utils import getattr_without_descriptor_read -from .data_classes import RemoteResourceInfoValidator +from .dataklasses import RemoteResourceInfoValidator from .property import Property from .properties import ClassSelector, TypedDict, Boolean from .events import Event @@ -67,9 +67,9 @@ def __init__(self, self.states = states self.initial_state = initial_state self.machine = machine - self.state_change_event = None - if push_state_change_event: - self.state_change_event = Event('state-change') + self.push_state_change_event = push_state_change_event + # if : + # self.state_change_event = Event('state-change') def _prepare(self, owner : Parameterized) -> None: if self.states is None and self.initial_state is None: @@ -81,7 +81,7 @@ def _prepare(self, owner : Parameterized) -> None: self._state = self._get_machine_compliant_state(self.initial_state) self.owner = owner - owner_properties = owner.properties.descriptors.values() + owner_properties = owner.parameters.descriptors.values() # same as owner.properties.descriptors.values() owner_methods = [obj[0] for obj in inspect._getmembers(owner, inspect.ismethod, getattr_without_descriptor_read)] if isinstance(self.states, list): @@ -175,8 +175,8 @@ def set_state(self, value : typing.Union[str, StrEnum, Enum], push_event : bool if value in self.states: previous_state = self._state self._state = self._get_machine_compliant_state(value) - if push_event and self.state_change_event is not None and self.state_change_event.publisher is not None: - self.state_change_event.push(value) + if push_event and self.push_state_change_event and hasattr(self.owner, 'event_publisher'): + self.owner.state # just acces to trigger the observable event if skip_callbacks: return if previous_state in self.on_exit: diff --git a/hololinked/server/td.py b/hololinked/server/td.py index 6279633..e553870 100644 --- a/hololinked/server/td.py +++ b/hololinked/server/td.py @@ -1,13 +1,16 @@ +import inspect import typing -import socket from dataclasses import dataclass, field -from .data_classes import RemoteResourceInfoValidator + +from .constants import JSON, JSONSerializable +from .utils import getattr_without_descriptor_read +from .dataklasses import ActionInfoValidator +from .events import Event from .properties import * -from .constants import JSONSerializable +from .property import Property from .thing import Thing -from .properties import Property -from .events import Event +from .eventloop import EventLoop @@ -42,11 +45,13 @@ def format_doc(cls, doc : str): """strip tabs, newlines, whitespaces etc.""" doc_as_list = doc.split('\n') final_doc = [] - for line in doc_as_list: + for index, line in enumerate(doc_as_list): line = line.lstrip('\n').rstrip('\n') line = line.lstrip('\t').rstrip('\t') line = line.lstrip('\n').rstrip('\n') - line = line.lstrip().rstrip() + line = line.lstrip().rstrip() + if index > 0: + line = ' ' + line # add space to left in case of new line final_doc.append(line) return ''.join(final_doc) @@ -65,7 +70,17 @@ class JSONSchema: dict : 'object', list : 'array', tuple : 'array', - type(None) : 'null' + type(None) : 'null', + Exception : { + "type": "object", + "properties": { + "message": {"type": "string"}, + "type": {"type": "string"}, + "traceback": {"type": "array", "items": {"type": "string"}}, + "notes": {"type": ["string", "null"]} + }, + "required": ["message", "type", "traceback"] + } } _schemas = { @@ -78,6 +93,13 @@ def is_allowed_type(cls, type : typing.Any) -> bool: return True return False + @classmethod + def is_supported(cls, typ: typing.Any) -> bool: + """""" + if typ in JSONSchema._schemas.keys(): + return True + return False + @classmethod def get_type(cls, typ : typing.Any) -> str: if not JSONSchema.is_allowed_type(typ): @@ -102,12 +124,6 @@ def register_type_replacement(self, type : typing.Any, json_schema_type : str, raise TypeError(f"json schema replacement type must be one of allowed type - 'string', 'object', 'array', 'string', " + f"'number', 'integer', 'boolean', 'null'. Given value {json_schema_type}") - @classmethod - def is_supported(cls, typ: typing.Any) -> bool: - if typ in JSONSchema._schemas.keys(): - return True - return False - @classmethod def get(cls, typ : typing.Any): """schema for array and objects only supported""" @@ -160,7 +176,7 @@ def __init__(self): def build(self, property : Property, owner : Thing, authority : str) -> None: """generates the schema""" - self.title = property.name # or property.label + self.title = property.label or property.name if property.constant: self.const = property.constant if property.readonly: @@ -199,23 +215,32 @@ def build(self, property : Property, owner : Thing, authority : str) -> None: """generates the schema""" DataSchema.build(self, property, owner, authority) - if property.observable: - self.observable = property.observable - self.forms = [] for index, method in enumerate(property._remote_info.http_method): form = Form() # index is the order for http methods for (get, set, delete), generally (GET, PUT, DELETE) if (index == 1 and property.readonly) or index >= 2: - continue # delete property is not a part of WoT, we also mostly never use it so ignore. + continue # delete property is not a part of WoT, we also mostly never use it, so ignore. elif index == 0: form.op = 'readproperty' elif index == 1: form.op = 'writeproperty' form.href = f"{authority}{owner._full_URL_path_prefix}{property._remote_info.URL_path}" form.htv_methodName = method.upper() + form.contentType = "application/json" self.forms.append(form.asdict()) + if property._observable: + self.observable = property._observable + form = Form() + form.op = 'observeproperty' + form.href = f"{authority}{owner._full_URL_path_prefix}{property._observable_event_descriptor.URL_path}" + form.htv_methodName = "GET" + form.subprotocol = "sse" + form.contentType = "text/plain" + self.forms.append(form.asdict()) + + @classmethod def generate_schema(self, property : Property, owner : Thing, authority : str) -> typing.Dict[str, JSONSerializable]: if not isinstance(property, Property): @@ -353,7 +378,7 @@ def build(self, property: Property, owner: Thing, authority: str) -> None: if isinstance(property.item_type, (list, tuple)): for typ in property.item_type: self.items.append(dict(type=JSONSchema.get_type(typ))) - else: + elif property.item_type is not None: self.items.append(dict(type=JSONSchema.get_type(property.item_type))) elif isinstance(property, TupleSelector): objects = list(property.objects) @@ -361,7 +386,9 @@ def build(self, property: Property, owner: Thing, authority: str) -> None: if any(types["type"] == JSONSchema._replacements.get(type(obj), None) for types in self.items): continue self.items.append(dict(type=JSONSchema.get_type(type(obj)))) - if len(self.items) > 1: + if len(self.items) == 0: + del self.items + elif len(self.items) > 1: self.items = dict(oneOf=self.items) @@ -456,7 +483,7 @@ def cleanup(self): oneOf = self.oneOf[0] self.type = oneOf["type"] if oneOf["type"] == 'object': - if oneOf.get("properites", NotImplemented) is not NotImplemented: + if oneOf.get("properties", NotImplemented) is not NotImplemented: self.properties = oneOf["properties"] if oneOf.get("required", NotImplemented) is not NotImplemented: self.required = oneOf["required"] @@ -470,6 +497,7 @@ def cleanup(self): del self.oneOf +@dataclass class EnumSchema(OneOfSchema): """ custom schema to fill enum field of property affordance correctly @@ -485,12 +513,21 @@ def build(self, property: Property, owner: Thing, authority: str) -> None: OneOfSchema.build(self, property, owner, authority) +@dataclass +class Link(Schema): + href : str + anchor : typing.Optional[str] + type : typing.Optional[str] = field(default='application/json') + rel : typing.Optional[str] = field(default='next') + def __init__(self): + super().__init__() + + def build(self, resource : Thing, owner : Thing, authority : str) -> None: + self.href = f"{authority}{resource._full_URL_path_prefix}/resources/wot-td" + self.anchor = f"{authority}{owner._full_URL_path_prefix}" -class Link: - pass - @dataclass class ExpectedResponse(Schema): @@ -503,20 +540,21 @@ class ExpectedResponse(Schema): def __init__(self): super().__init__() + @dataclass class AdditionalExpectedResponse(Schema): """ - Form property. + Form field for additional responses which are different from the usual response. schema - https://www.w3.org/TR/wot-thing-description11/#additionalexpectedresponse """ - success : bool - contentType : str - schema : typing.Optional[typing.Dict[str, typing.Any]] + success : bool = field(default=False) + contentType : str = field(default='application/json') + schema : typing.Optional[JSON] = field(default='exception') def __init__(self): super().__init__() - + @dataclass class Form(Schema): """ @@ -524,22 +562,20 @@ class Form(Schema): schema - https://www.w3.org/TR/wot-thing-description11/#form """ href : str + op : str + htv_methodName : str + contentType : typing.Optional[str] + additionalResponses : typing.Optional[typing.List[AdditionalExpectedResponse]] contentEncoding : typing.Optional[str] security : typing.Optional[str] scopes : typing.Optional[str] response : typing.Optional[ExpectedResponse] - additionalResponses : typing.Optional[typing.List[AdditionalExpectedResponse]] subprotocol : typing.Optional[str] - op : str - htv_methodName : str - subprotocol : str - contentType : typing.Optional[str] = field(default='application/json') def __init__(self): super().__init__() - @dataclass class ActionAffordance(InteractionAffordance): """ @@ -556,7 +592,7 @@ def __init__(self): super(InteractionAffordance, self).__init__() def build(self, action : typing.Callable, owner : Thing, authority : str) -> None: - assert isinstance(action._remote_info, RemoteResourceInfoValidator) + assert isinstance(action._remote_info, ActionInfoValidator) if action._remote_info.argument_schema: self.input = action._remote_info.argument_schema if action._remote_info.return_value_schema: @@ -564,19 +600,21 @@ def build(self, action : typing.Callable, owner : Thing, authority : str) -> Non self.title = action.__name__ if action.__doc__: self.description = self.format_doc(action.__doc__) - self.safe = True - if (hasattr(owner, 'state_machine') and owner.state_machine is not None and - owner.state_machine.has_object(action._remote_info.obj)): - self.idempotent = False - else: - self.idempotent = True - self.synchronous = True + if not (hasattr(owner, 'state_machine') and owner.state_machine is not None and + owner.state_machine.has_object(action._remote_info.obj)) and action._remote_info.idempotent: + self.idempotent = action._remote_info.idempotent + if action._remote_info.synchronous: + self.synchronous = action._remote_info.synchronous + if action._remote_info.safe: + self.safe = action._remote_info.safe self.forms = [] for method in action._remote_info.http_method: form = Form() form.op = 'invokeaction' form.href = f'{authority}{owner._full_URL_path_prefix}{action._remote_info.URL_path}' form.htv_methodName = method.upper() + form.contentType = 'application/json' + # form.additionalResponses = [AdditionalExpectedResponse().asdict()] self.forms.append(form.asdict()) @classmethod @@ -599,11 +637,17 @@ def __init__(self): super().__init__() def build(self, event : Event, owner : Thing, authority : str) -> None: + self.title = event.label or event._obj_name + if event.doc: + self.description = self.format_doc(event.doc) + if event.schema: + self.data = event.schema + form = Form() form.op = "subscribeevent" form.href = f"{authority}{owner._full_URL_path_prefix}{event.URL_path}" - form.contentType = "text/event-stream" form.htv_methodName = "GET" + form.contentType = "text/plain" form.subprotocol = "sse" self.forms = [form.asdict()] @@ -656,9 +700,7 @@ class ThingDescription(Schema): type : typing.Optional[typing.Union[str, typing.List[str]]] id : str title : str - titles : typing.Optional[typing.Dict[str, str]] description : str - descriptions : typing.Optional[typing.Dict[str, str]] version : typing.Optional[VersionInfo] created : typing.Optional[str] modified : typing.Optional[str] @@ -671,49 +713,122 @@ class ThingDescription(Schema): forms : typing.Optional[typing.List[Form]] security : typing.Union[str, typing.List[str]] securityDefinitions : SecurityScheme - - skip_properties = ['expose', 'httpserver_resources', 'rpc_resources', 'gui_resources', + schemaDefinitions : typing.Optional[typing.List[DataSchema]] + + skip_properties = ['expose', 'httpserver_resources', 'zmq_resources', 'gui_resources', 'events', 'debug_logs', 'warn_logs', 'info_logs', 'error_logs', 'critical_logs', 'thing_description', 'maxlen', 'execution_logs', 'GUI', 'object_info' ] - skip_actions = ['_set_properties', '_get_properties', 'push_events', 'stop_events', - 'postman_collection'] + skip_actions = ['_set_properties', '_get_properties', '_add_property', 'push_events', 'stop_events', + 'get_postman_collection', 'get_thing_description'] - def __init__(self): + # not the best code and logic, but works for now + + def __init__(self, instance : Thing, authority : typing.Optional[str] = None, + allow_loose_schema : typing.Optional[bool] = False) -> None: super().__init__() - - def build(self, instance : Thing, authority = f"https://{socket.gethostname()}:8080", - allow_loose_schema : typing.Optional[bool] = False) -> typing.Dict[str, typing.Any]: + self.instance = instance + self.authority = authority + self.allow_loose_schema = allow_loose_schema + + + def produce(self) -> typing.Dict[str, typing.Any]: self.context = "https://www.w3.org/2022/wot/td/v1.1" - self.id = f"{authority}/{instance.instance_name}" - self.title = instance.__class__.__name__ - self.description = Schema.format_doc(instance.__doc__) if instance.__doc__ else "no class doc provided" + self.id = f"{self.authority}/{self.instance.instance_name}" + self.title = self.instance.__class__.__name__ + self.description = Schema.format_doc(self.instance.__doc__) if self.instance.__doc__ else "no class doc provided" self.properties = dict() self.actions = dict() self.events = dict() + self.forms = NotImplemented + self.links = NotImplemented + + # self.schemaDefinitions = dict(exception=JSONSchema.get_type(Exception)) + self.add_interaction_affordances() + self.add_top_level_forms() + self.add_security_definitions() + + return self.asdict() + + + def add_interaction_affordances(self): # properties and actions - for resource in instance.instance_resources.values(): + for resource in self.instance.instance_resources.values(): if (resource.isproperty and resource.obj_name not in self.properties and resource.obj_name not in self.skip_properties and hasattr(resource.obj, "_remote_info") and resource.obj._remote_info is not None): - self.properties[resource.obj_name] = PropertyAffordance.generate_schema(resource.obj, instance, authority) + if (resource.obj_name == 'state' and hasattr(self.instance, 'state_machine') is None and + self.instance.state_machine is not None): + continue + self.properties[resource.obj_name] = PropertyAffordance.generate_schema(resource.obj, + self.instance, self.authority) elif (resource.isaction and resource.obj_name not in self.actions and resource.obj_name not in self.skip_actions and hasattr(resource.obj, '_remote_info')): - self.actions[resource.obj_name] = ActionAffordance.generate_schema(resource.obj, instance, authority) + self.actions[resource.obj_name] = ActionAffordance.generate_schema(resource.obj, + self.instance, self.authority) # Events - for name, resource in vars(instance).items(): + for name, resource in inspect._getmembers(self.instance, lambda o : isinstance(o, Event), + getattr_without_descriptor_read): if not isinstance(resource, Event): continue - self.events[name] = EventAffordance.generate_schema(resource, instance, authority) + if '/change-event' in resource.URL_path: + continue + self.events[name] = EventAffordance.generate_schema(resource, self.instance, self.authority) + # for name, resource in inspect._getmembers(self.instance, lambda o : isinstance(o, Thing), getattr_without_descriptor_read): + # if resource is self.instance or isinstance(resource, EventLoop): + # continue + # if self.links is None: + # self.links = [] + # link = Link() + # link.build(resource, self.instance, self.authority) + # self.links.append(link.asdict()) + - self.security = 'unimplemented' - self.securityDefinitions = SecurityScheme().build('unimplemented', instance) + def add_top_level_forms(self): - return self.asdict() - + self.forms = [] + + properties_end_point = f"{self.authority}{self.instance._full_URL_path_prefix}/properties" + + readallproperties = Form() + readallproperties.href = properties_end_point + readallproperties.op = "readallproperties" + readallproperties.htv_methodName = "GET" + readallproperties.contentType = "application/json" + # readallproperties.additionalResponses = [AdditionalExpectedResponse().asdict()] + self.forms.append(readallproperties.asdict()) + writeallproperties = Form() + writeallproperties.href = properties_end_point + writeallproperties.op = "writeallproperties" + writeallproperties.htv_methodName = "PUT" + writeallproperties.contentType = "application/json" + # writeallproperties.additionalResponses = [AdditionalExpectedResponse().asdict()] + self.forms.append(writeallproperties.asdict()) + + readmultipleproperties = Form() + readmultipleproperties.href = properties_end_point + readmultipleproperties.op = "readmultipleproperties" + readmultipleproperties.htv_methodName = "GET" + readmultipleproperties.contentType = "application/json" + # readmultipleproperties.additionalResponses = [AdditionalExpectedResponse().asdict()] + self.forms.append(readmultipleproperties.asdict()) + + writemultipleproperties = Form() + writemultipleproperties.href = properties_end_point + writemultipleproperties.op = "writemultipleproperties" + writemultipleproperties.htv_methodName = "PATCH" + writemultipleproperties.contentType = "application/json" + # writemultipleproperties.additionalResponses = [AdditionalExpectedResponse().asdict()] + self.forms.append(writemultipleproperties.asdict()) + def add_security_definitions(self): + self.security = 'unimplemented' + self.securityDefinitions = SecurityScheme().build('unimplemented', self.instance) + + + __all__ = [ ThingDescription.__name__, JSONSchema.__name__ diff --git a/hololinked/server/thing.py b/hololinked/server/thing.py index e137ceb..bbdcf0f 100644 --- a/hololinked/server/thing.py +++ b/hololinked/server/thing.py @@ -5,14 +5,16 @@ import typing import warnings import zmq +import zmq.asyncio -from ..param.parameterized import Parameterized, ParameterizedMetaclass -from .constants import (LOGLEVEL, ZMQ_PROTOCOLS, HTTP_METHODS) +from ..param.parameterized import Parameterized, ParameterizedMetaclass, edit_constant as edit_constant_parameters +from .constants import (JSON, LOGLEVEL, ZMQ_PROTOCOLS, HTTP_METHODS) from .database import ThingDB, ThingInformation from .serializers import _get_serializer_from_user_given_options, BaseSerializer, JSONSerializer +from .schema_validators import BaseSchemaValidator, JsonSchemaValidator from .exceptions import BreakInnerLoop from .action import action -from .data_classes import GUIResources, HTTPResource, RPCResource, get_organised_resources +from .dataklasses import GUIResources, HTTPResource, ZMQResource, get_organised_resources from .utils import get_default_logger, getattr_without_descriptor_read from .property import Property, ClassProperties from .properties import String, ClassSelector, Selector, TypedKeyMappingsConstrainedDict @@ -22,6 +24,7 @@ + class ThingMeta(ParameterizedMetaclass): """ Metaclass for Thing, implements a ``__post_init__()`` call and instantiation of a container for properties' descriptor @@ -90,28 +93,31 @@ class Thing(Parameterized, metaclass=ThingMeta): doc="""logging.Logger instance to print log messages. Default logger with a IO-stream handler and network accessible handler is created if none supplied.""") # type: logging.Logger - rpc_serializer = ClassSelector(class_=(BaseSerializer, str), + zmq_serializer = ClassSelector(class_=(BaseSerializer, str), allow_None=True, default='json', remote=False, doc="""Serializer used for exchanging messages with python RPC clients. Subclass the base serializer or one of the available serializers to implement your own serialization requirements; or, register type replacements. Default is JSON. Some serializers like MessagePack improve performance many times compared to JSON and can be useful for data intensive applications within python.""") # type: BaseSerializer - json_serializer = ClassSelector(class_=(JSONSerializer, str), default=None, allow_None=True, remote=False, + http_serializer = ClassSelector(class_=(JSONSerializer, str), default=None, allow_None=True, remote=False, doc="""Serializer used for exchanging messages with a HTTP clients, subclass JSONSerializer to implement your own JSON serialization requirements; or, register type replacements. Other types of serializers are currently not allowed for HTTP clients.""") # type: JSONSerializer - + schema_validator = ClassSelector(class_=BaseSchemaValidator, default=JsonSchemaValidator, allow_None=True, + remote=False, isinstance=False, + doc="""Validator for JSON schema. If not supplied, a default JSON schema validator is created.""") # type: BaseSchemaValidator + # remote paramerters - state = String(default=None, allow_None=True, URL_path='/state', readonly=True, - fget= lambda self : self.state_machine.current_state if hasattr(self, 'state_machine') else None, + state = String(default=None, allow_None=True, URL_path='/state', readonly=True, observable=True, + fget=lambda self : self.state_machine.current_state if hasattr(self, 'state_machine') else None, doc="current state machine's state if state machine present, None indicates absence of state machine.") #type: typing.Optional[str] httpserver_resources = Property(readonly=True, URL_path='/resources/http-server', doc="object's resources exposed to HTTP client (through ``hololinked.server.HTTPServer.HTTPServer``)", fget=lambda self: self._httpserver_resources ) # type: typing.Dict[str, HTTPResource] - rpc_resources = Property(readonly=True, URL_path='/resources/object-proxy', + zmq_resources = Property(readonly=True, URL_path='/resources/zmq-object-proxy', doc="object's resources exposed to RPC client, similar to HTTP resources but differs in details.", - fget=lambda self: self._rpc_resources) # type: typing.Dict[str, RPCResource] + fget=lambda self: self._zmq_resources) # type: typing.Dict[str, ZMQResource] gui_resources = Property(readonly=True, URL_path='/resources/portal-app', doc="""object's data read by hololinked-portal GUI client, similar to http_resources but differs in details.""", @@ -127,7 +133,8 @@ def __new__(cls, *args, **kwargs): # defines some internal fixed attributes. attributes created by us that require no validation but # cannot be modified are called _internal_fixed_attributes obj._internal_fixed_attributes = ['_internal_fixed_attributes', 'instance_resources', - '_httpserver_resources', '_rpc_resources', '_owner'] + '_httpserver_resources', '_zmq_resources', '_owner', 'rpc_server', 'message_broker', + '_event_publisher'] return obj @@ -146,9 +153,9 @@ def __init__(self, *, instance_name : str, logger : typing.Optional[logging.Logg accessible handler is created if none supplied. serializer: JSONSerializer, optional custom JSON serializer. To use separate serializer for python RPC clients and cross-platform - HTTP clients, use keyword arguments rpc_serializer and json_serializer and leave this argument at None. + HTTP clients, use keyword arguments zmq_serializer and http_serializer and leave this argument at None. **kwargs: - rpc_serializer: BaseSerializer | str, optional + zmq_serializer: BaseSerializer | str, optional Serializer used for exchanging messages with python RPC clients. If string value is supplied, supported are 'msgpack', 'pickle', 'serpent', 'json'. Subclass the base serializer ``hololinked.server.serializer.BaseSerializer`` or one of the available serializers to implement your @@ -156,7 +163,7 @@ def __init__(self, *, instance_name : str, logger : typing.Optional[logging.Logg MessagePack improve performance many times compared to JSON and can be useful for data intensive applications within python. The serializer supplied here must also be supplied to object proxy from ``hololinked.client``. - json_serializer: JSONSerializer, optional + http_serializer: JSONSerializer, optional serializer used for cross platform HTTP clients. use_default_db: bool, Default False if True, default SQLite database is created where properties can be stored and loaded. There is no need to supply @@ -164,6 +171,8 @@ def __init__(self, *, instance_name : str, logger : typing.Optional[logging.Logg logger_remote_access: bool, Default True if False, network accessible handler is not attached to the logger. This value can also be set as a class attribute, see docs. + schema_validator: BaseSchemaValidator, optional + schema validator class for JSON schema validation, not supported by ZMQ clients. db_config_file: str, optional if not using a default database, supply a JSON configuration file to create a connection. Check documentaion of ``hololinked.server.database``. @@ -171,17 +180,26 @@ class attribute, see docs. """ if instance_name.startswith('/'): instance_name = instance_name[1:] + # Type definitions + self._owner : typing.Optional[Thing] = None + self._internal_fixed_attributes : typing.List[str] + self._full_URL_path_prefix : str + self.rpc_server = None # type: typing.Optional[RPCServer] + self.message_broker = None # type : typing.Optional[AsyncPollingZMQServer] + self._event_publisher = None # type : typing.Optional[EventPublisher] + self._gui = None # filler for a future feature + # serializer if not isinstance(serializer, JSONSerializer) and serializer != 'json' and serializer is not None: raise TypeError("serializer key word argument must be JSONSerializer. If one wishes to use separate serializers " + - "for python clients and HTTP clients, use rpc_serializer and json_serializer keyword arguments.") - rpc_serializer = serializer or kwargs.pop('rpc_serializer', 'json') - json_serializer = serializer if isinstance(serializer, JSONSerializer) else kwargs.pop('json_serializer', 'json') - rpc_serializer, json_serializer = _get_serializer_from_user_given_options( - rpc_serializer=rpc_serializer, - json_serializer=json_serializer + "for python clients and HTTP clients, use zmq_serializer and http_serializer keyword arguments.") + zmq_serializer = serializer or kwargs.pop('zmq_serializer', 'json') + http_serializer = serializer if isinstance(serializer, JSONSerializer) else kwargs.pop('http_serializer', 'json') + zmq_serializer, http_serializer = _get_serializer_from_user_given_options( + zmq_serializer=zmq_serializer, + http_serializer=http_serializer ) super().__init__(instance_name=instance_name, logger=logger, - rpc_serializer=rpc_serializer, json_serializer=json_serializer, **kwargs) + zmq_serializer=zmq_serializer, http_serializer=http_serializer, **kwargs) self._prepare_logger( log_level=kwargs.get('log_level', None), @@ -194,23 +212,11 @@ class attribute, see docs. def __post_init__(self): - self._owner : typing.Optional[Thing] = None - self._internal_fixed_attributes : typing.List[str] - self._full_URL_path_prefix : str - self.rpc_server : typing.Optional[RPCServer] - self.message_broker : typing.Optional[AsyncPollingZMQServer] - self._event_publisher : typing.Optional[EventPublisher] - self._gui = None # filler for a future feature self._prepare_resources() self.load_properties_from_DB() self.logger.info(f"initialialised Thing class {self.__class__.__name__} with instance name {self.instance_name}") - @property - def properties(self): - return self.parameters - - def __setattr__(self, __name: str, __value: typing.Any) -> None: if __name == '_internal_fixed_attributes' or __name in self._internal_fixed_attributes: # order of 'or' operation for above 'if' matters @@ -230,7 +236,7 @@ def _prepare_resources(self): and extracts information necessary to make RPC functionality work. """ # The following dict is to be given to the HTTP server - self._rpc_resources, self._httpserver_resources, self.instance_resources = get_organised_resources(self) + self._zmq_resources, self._httpserver_resources, self.instance_resources = get_organised_resources(self) def _prepare_logger(self, log_level : int, log_file : str, remote_access : bool = False): @@ -266,7 +272,7 @@ def _prepare_DB(self, default_db : bool = False, config_file : str = None): return # 1. create engine self.db_engine = ThingDB(instance=self, config_file=None if default_db else config_file, - serializer=self.rpc_serializer) # type: ThingDB + serializer=self.zmq_serializer) # type: ThingDB # 2. create an object metadata to be used by different types of clients object_info = self.db_engine.fetch_own_info() if object_info is not None: @@ -274,7 +280,7 @@ def _prepare_DB(self, default_db : bool = False, config_file : str = None): # 3. enter properties to DB if not already present if self.object_info.class_name != self.__class__.__name__: raise ValueError("Fetched instance name and class name from database not matching with the ", - " current Thing class/subclass. You might be reusing an instance name of another subclass ", + "current Thing class/subclass. You might be reusing an instance name of another subclass ", "and did not remove the old data from database. Please clean the database using database tools to ", "start fresh.") @@ -297,32 +303,50 @@ def _get_object_info(self): @object_info.setter def _set_object_info(self, value): self._object_info = ThingInformation(**value) - + + @property + def properties(self) -> ClassProperties: + """container for the property descriptors of the object.""" + return self.parameters + @action(URL_path='/properties', http_method=HTTP_METHODS.GET) def _get_properties(self, **kwargs) -> typing.Dict[str, typing.Any]: """ """ + skip_props = ["httpserver_resources", "zmq_resources", "gui_resources", "GUI", "object_info"] + for prop_name in skip_props: + if prop_name in kwargs: + raise RuntimeError("GUI, httpserver resources, RPC resources , object info etc. cannot be queried" + + " using multiple property fetch.") data = {} if len(kwargs) == 0: - for parameter in self.properties.descriptors.keys(): - data[parameter] = self.properties[parameter].__get__(self, type(self)) + for name, prop in self.properties.descriptors.items(): + if name in skip_props or not isinstance(prop, Property): + continue + if prop._remote_info is None: + continue + data[name] = prop.__get__(self, type(self)) elif 'names' in kwargs: names = kwargs.get('names') if not isinstance(names, (list, tuple, str)): raise TypeError(f"Specify properties to be fetched as a list, tuple or comma separated names. Givent type {type(names)}") if isinstance(names, str): names = names.split(',') - for requested_parameter in names: - if not isinstance(requested_parameter, str): - raise TypeError(f"parameter name must be a string. Given type {type(requested_parameter)}") - data[requested_parameter] = self.properties[requested_parameter].__get__(self, type(self)) + for requested_prop in names: + if not isinstance(requested_prop, str): + raise TypeError(f"property name must be a string. Given type {type(requested_prop)}") + if not isinstance(self.properties[requested_prop], Property) or self.properties[requested_prop]._remote_info is None: + raise AttributeError("this property is not remote accessible") + data[requested_prop] = self.properties[requested_prop].__get__(self, type(self)) elif len(kwargs.keys()) != 0: - for rename, requested_parameter in kwargs.items(): - data[rename] = self.properties[requested_parameter].__get__(self, type(self)) + for rename, requested_prop in kwargs.items(): + if not isinstance(self.properties[requested_prop], Property) or self.properties[requested_prop]._remote_info is None: + raise AttributeError("this property is not remote accessible") + data[rename] = self.properties[requested_prop].__get__(self, type(self)) return data - @action(URL_path='/properties', http_method=HTTP_METHODS.PATCH) + @action(URL_path='/properties', http_method=[HTTP_METHODS.PUT, HTTP_METHODS.PATCH]) def _set_properties(self, **values : typing.Dict[str, typing.Any]) -> None: """ set properties whose name is specified by keys of a dictionary @@ -330,11 +354,40 @@ def _set_properties(self, **values : typing.Dict[str, typing.Any]) -> None: Parameters ---------- values: Dict[str, Any] - dictionary of parameter names and its values + dictionary of property names and its values """ + produced_error = False + errors = '' for name, value in values.items(): - setattr(self, name, value) - + try: + setattr(self, name, value) + except Exception as ex: + self.logger.error(f"could not set attribute {name} due to error {str(ex)}") + errors += f'{name} : {str(ex)}\n' + produced_error = True + if produced_error: + ex = RuntimeError("Some properties could not be set due to errors. " + + "Check exception notes or server logs for more information.") + ex.__notes__ = errors + raise ex from None + + @action(URL_path='/properties', http_method=HTTP_METHODS.POST) + def _add_property(self, name : str, prop : JSON) -> None: + """ + add a property to the object + + Parameters + ---------- + name: str + name of the property + prop: Property + property object + """ + raise NotImplementedError("this method will be implemented properly in a future release") + prop = Property(**prop) + self.properties.add(name, prop) + self._prepare_resources() + # instruct the clients to fetch the new resources @property def event_publisher(self) -> EventPublisher: @@ -342,35 +395,25 @@ def event_publisher(self) -> EventPublisher: event publishing PUB socket owning object, valid only after ``run()`` is called, otherwise raises AttributeError. """ - try: - return self._event_publisher - except AttributeError: - raise AttributeError("event publisher not yet created.") from None - + return self._event_publisher + @event_publisher.setter def event_publisher(self, value : EventPublisher) -> None: - if hasattr(self, '_event_publisher'): - raise AttributeError("Can set event publisher only once.") + if self._event_publisher is not None: + raise AttributeError("Can set event publisher only once") def recusively_set_event_publisher(obj : Thing, publisher : EventPublisher) -> None: for name, evt in inspect._getmembers(obj, lambda o: isinstance(o, Event), getattr_without_descriptor_read): assert isinstance(evt, Event), "object is not an event" # above is type definition - evt.publisher = publisher - evt._remote_info.socket_address = publisher.socket_address - for prop in self.properties.descriptors.values(): - if prop.observable: - assert isinstance(prop._observable_event, Event), "observable event logic error in event_publisher set" - prop._observable_event.publisher = publisher - prop._observable_event._remote_info.socket_address = publisher.socket_address - if (hasattr(obj, 'state_machine') and isinstance(obj.state_machine, StateMachine) and - obj.state_machine.state_change_event is not None): - obj.state_machine.state_change_event.publisher = publisher - obj.state_machine.state_change_event._remote_info.socket_address = publisher.socket_address - for name, obj in inspect._getmembers(obj, lambda o: isinstance(o, Thing), getattr_without_descriptor_read): + e = evt.__get__(obj, type(obj)) + e.publisher = publisher + e._remote_info.socket_address = publisher.socket_address + self.logger.info(f"registered event '{evt.friendly_name}' serving at PUB socket with address : {publisher.socket_address}") + for name, subobj in inspect._getmembers(obj, lambda o: isinstance(o, Thing), getattr_without_descriptor_read): if name == '_owner': continue - recusively_set_event_publisher(obj, publisher) + recusively_set_event_publisher(subobj, publisher) obj._event_publisher = publisher recusively_set_event_publisher(self, value) @@ -379,23 +422,22 @@ def recusively_set_event_publisher(obj : Thing, publisher : EventPublisher) -> N @action(URL_path='/properties/db-reload', http_method=HTTP_METHODS.POST) def load_properties_from_DB(self): """ - Load and apply parameter values which have ``db_init`` or ``db_persist`` + Load and apply property values which have ``db_init`` or ``db_persist`` set to ``True`` from database """ if not hasattr(self, 'db_engine'): return - for name, resource in inspect._getmembers(self, lambda o : isinstance(o, Thing), getattr_without_descriptor_read): - if name == '_owner': - continue missing_properties = self.db_engine.create_missing_properties(self.__class__.properties.db_init_objects, - get_missing_properties=True) + get_missing_property_names=True) # 4. read db_init and db_persist objects - for db_param in self.db_engine.get_all_properties(): - try: - if db_param.name not in missing_properties: - setattr(obj, db_param.name, db_param.value) # type: ignore - except Exception as ex: - self.logger.error(f"could not set attribute {db_param.name} due to error {str(ex)}") + with edit_constant_parameters(self): + for db_prop, value in self.db_engine.get_all_properties().items(): + try: + prop_desc = self.properties.descriptors[db_prop] + if (prop_desc.db_init or prop_desc.db_persist) and db_prop not in missing_properties: + setattr(self, db_prop, value) # type: ignore + except Exception as ex: + self.logger.error(f"could not set attribute {db_prop} due to error {str(ex)}") @action(URL_path='/resources/postman-collection', http_method=HTTP_METHODS.GET) @@ -403,10 +445,11 @@ def get_postman_collection(self, domain_prefix : str = None): """ organised postman collection for this object """ - from .api_platform_utils import postman_collection + from .api_platforms import postman_collection return postman_collection.build(instance=self, domain_prefix=domain_prefix if domain_prefix is not None else self._object_info.http_server) + @action(URL_path='/resources/wot-td', http_method=HTTP_METHODS.GET) def get_thing_description(self, authority : typing.Optional[str] = None): # allow_loose_schema : typing.Optional[bool] = False): @@ -433,10 +476,10 @@ def get_thing_description(self, authority : typing.Optional[str] = None): # value for node-wot to ignore validation or claim the accessed value for complaint with the schema. # In other words, schema validation will always pass. from .td import ThingDescription - return ThingDescription().build(self, authority or self._object_info.http_server, - allow_loose_schema=False) #allow_loose_schema) - - + return ThingDescription(instance=self, authority=authority or self._object_info.http_server, + allow_loose_schema=False).produce() #allow_loose_schema) + + @action(URL_path='/exit', http_method=HTTP_METHODS.POST) def exit(self) -> None: """ @@ -444,8 +487,11 @@ def exit(self) -> None: started using the run() method, the eventloop is also killed. This method can only be called remotely. """ + if self.rpc_server is None: + return if self._owner is None: - raise BreakInnerLoop + self.rpc_server.stop_polling() + raise BreakInnerLoop # stops the inner loop of the object else: warnings.warn("call exit on the top object, composed objects cannot exit the loop.", RuntimeWarning) @@ -475,21 +521,27 @@ def run(self, **kwargs tcp_socket_address: str, optional socket_address for TCP access, for example: tcp://0.0.0.0:61234 + context: zmq.asyncio.Context, optional + zmq context to be used. If not supplied, a new context is created. + For INPROC clients, you need to provide a context. """ # expose_eventloop: bool, False # expose the associated Eventloop which executes the object. This is generally useful for remotely # adding more objects to the same event loop. # dont specify http server as a kwarg, as the other method run_with_http_server has to be used - - context = zmq.asyncio.Context() + context = kwargs.get('context', None) + if context is not None and not isinstance(context, zmq.asyncio.Context): + raise TypeError("context must be an instance of zmq.asyncio.Context") + context = context or zmq.asyncio.Context() + self.rpc_server = RPCServer( instance_name=self.instance_name, server_type=self.__server_type__.value, context=context, protocols=zmq_protocols, - rpc_serializer=self.rpc_serializer, - json_serializer=self.json_serializer, - socket_address=kwargs.get('tcp_socket_address', None), + zmq_serializer=self.zmq_serializer, + http_serializer=self.http_serializer, + tcp_socket_address=kwargs.get('tcp_socket_address', None), logger=self.logger ) self.message_broker = self.rpc_server.inner_inproc_server @@ -500,8 +552,8 @@ def run(self, instance_name=f'{self.instance_name}/eventloop', things=[self], logger=self.logger, - rpc_serializer=self.rpc_serializer, - json_serializer=self.json_serializer, + zmq_serializer=self.zmq_serializer, + http_serializer=self.http_serializer, expose=False, # expose_eventloop ) @@ -557,17 +609,20 @@ def run_with_http_server(self, port : int = 8080, address : str = '0.0.0.0', from .HTTPServer import HTTPServer http_server = HTTPServer( - [self.instance_name], logger=self.logger, serializer=self.json_serializer, + [self.instance_name], logger=self.logger, serializer=self.http_serializer, port=port, address=address, ssl_context=ssl_context, - allowed_clients=allowed_clients, + allowed_clients=allowed_clients, schema_validator=self.schema_validator, # network_interface=network_interface, **kwargs, ) self.run( zmq_protocols=ZMQ_PROTOCOLS.INPROC, - http_server=http_server - ) + http_server=http_server, + context=kwargs.get('context', None) + ) # blocks until exit is called + + http_server.tornado_instance.stop() diff --git a/hololinked/server/utils.py b/hololinked/server/utils.py index e57fdf4..72ebf52 100644 --- a/hololinked/server/utils.py +++ b/hololinked/server/utils.py @@ -56,7 +56,7 @@ def format_exception_as_json(exc : Exception) -> typing.Dict[str, typing.Any]: } -def pep8_to_dashed_URL(word : str) -> str: +def pep8_to_URL_path(word : str) -> str: """ Make an underscored, lowercase form from the expression in the string. Example:: @@ -125,12 +125,16 @@ def run_callable_somehow(method : typing.Union[typing.Callable, typing.Coroutine eventloop = asyncio.get_event_loop() except RuntimeError: eventloop = asyncio.new_event_loop() + if asyncio.iscoroutinefunction(method): + coro = method() + else: + coro = method if eventloop.is_running(): - task = lambda : asyncio.create_task(method) # check later if lambda is necessary - eventloop.call_soon(task) + # task = # check later if lambda is necessary + eventloop.create_task(coro) else: - task = method - return eventloop.run_until_complete(task) + # task = method + return eventloop.run_until_complete(coro) def get_signature(callable : typing.Callable) -> typing.Tuple[typing.List[str], typing.List[type]]: @@ -181,14 +185,53 @@ def getattr_without_descriptor_read(instance, key): return getattr(instance, key, None) # we can deal with None where we use this getter, so dont raise AttributeError +def isclassmethod(method): + """https://stackoverflow.com/questions/19227724/check-if-a-function-uses-classmethod""" + bound_to = getattr(method, '__self__', None) + if not isinstance(bound_to, type): + # must be bound to a class + return False + name = method.__name__ + for cls in bound_to.__mro__: + descriptor = vars(cls).get(name) + if descriptor is not None: + return isinstance(descriptor, classmethod) + return False + + +def issubklass(obj, cls): + """ + Safely check if `obj` is a subclass of `cls`. + + Parameters: + obj: The object to check if it's a subclass. + cls: The class (or tuple of classes) to compare against. + + Returns: + bool: True if `obj` is a subclass of `cls`, False otherwise. + """ + try: + # Check if obj is a class or a tuple of classes + if isinstance(obj, type): + return issubclass(obj, cls) + elif isinstance(obj, tuple): + # Ensure all elements in the tuple are classes + return all(isinstance(o, type) for o in obj) and issubclass(obj, cls) + else: + return False + except TypeError: + return False + + __all__ = [ get_IP_from_interface.__name__, format_exception_as_json.__name__, - pep8_to_dashed_URL.__name__, + pep8_to_URL_path.__name__, get_default_logger.__name__, run_coro_sync.__name__, run_callable_somehow.__name__, - get_signature.__name__ + get_signature.__name__, + isclassmethod.__name__ ] diff --git a/hololinked/server/zmq_message_brokers.py b/hololinked/server/zmq_message_brokers.py index f695b8e..ee6e481 100644 --- a/hololinked/server/zmq_message_brokers.py +++ b/hololinked/server/zmq_message_brokers.py @@ -1,12 +1,12 @@ import builtins import os +import threading import time import zmq import zmq.asyncio import asyncio import logging import typing -# import jsonschema from uuid import uuid4 from collections import deque from enum import Enum @@ -29,6 +29,7 @@ INTERRUPT = b'INTERRUPT' ONEWAY = b'ONEWAY' SERVER_DISCONNECTED = 'EVENT_DISCONNECTED' +EXIT = b'EXIT' EVENT = b'EVENT' EVENT_SUBSCRIPTION = b'EVENT_SUBSCRIPTION' @@ -88,7 +89,7 @@ def get_socket_type_name(socket_type): class BaseZMQ: """ Base class for all ZMQ message brokers. Implements socket creation, logger, serializer instantiation - which is common to all server and client implementations. For HTTP clients, json_serializer is necessary and + which is common to all server and client implementations. For HTTP clients, http_serializer is necessary and for RPC clients, any of the allowed serializer is possible. Parameters @@ -97,9 +98,9 @@ class BaseZMQ: instance name of the serving ``Thing`` server_type: Enum metadata about the nature of the server - json_serializer: hololinked.server.serializers.JSONSerializer + http_serializer: hololinked.server.serializers.JSONSerializer serializer used to send message to HTTP Server - rpc_serializer: any of hololinked.server.serializers.serializer, default serpent + zmq_serializer: any of hololinked.server.serializers.serializer, default serpent serializer used to send message to RPC clients logger: logging.Logger, Optional logger, on will be created while creating a socket automatically if None supplied @@ -208,8 +209,8 @@ def create_socket(self, *, identity : str, bind : bool, context : typing.Union[z if not self.logger: self.logger = get_default_logger('{}|{}|{}|{}'.format(self.__class__.__name__, socket_type, protocol, identity), kwargs.get('log_level', logging.INFO)) - self.logger.info("created socket {} with address {} and {}".format(get_socket_type_name(socket_type), socket_address, - "bound" if bind else "connected")) + self.logger.info("created socket {} with address {} & identity {} and {}".format(get_socket_type_name(socket_type), socket_address, + identity, "bound" if bind else "connected")) class BaseAsyncZMQ(BaseZMQ): @@ -242,8 +243,11 @@ def create_socket(self, *, identity : str, bind : bool = False, context : typing Overloads ``create_socket()`` to create, bind/connect a synchronous socket. A (synchronous) context is created if none is supplied. """ - if context and not isinstance(context, zmq.Context): - raise TypeError("sync ZMQ message broker accepts only sync ZMQ context. supplied type {}".format(type(context))) + if context: + if not isinstance(context, zmq.Context): + raise TypeError("sync ZMQ message broker accepts only sync ZMQ context. supplied type {}".format(type(context))) + if isinstance(context, zmq.asyncio.Context): + raise TypeError("sync ZMQ message broker accepts only sync ZMQ context. supplied type {}".format(type(context))) context = context or zmq.Context() super().create_socket(identity=identity, bind=bind, context=context, protocol=protocol, socket_type=socket_type, **kwargs) @@ -273,15 +277,15 @@ class BaseZMQServer(BaseZMQ): def __init__(self, instance_name : str, server_type : typing.Union[bytes, str], - json_serializer : typing.Union[None, JSONSerializer] = None, - rpc_serializer : typing.Union[str, BaseSerializer, None] = None, + http_serializer : typing.Union[None, JSONSerializer] = None, + zmq_serializer : typing.Union[str, BaseSerializer, None] = None, logger : typing.Optional[logging.Logger] = None, **kwargs ) -> None: super().__init__() - self.rpc_serializer, self.json_serializer = _get_serializer_from_user_given_options( - rpc_serializer=rpc_serializer, - json_serializer=json_serializer + self.zmq_serializer, self.http_serializer = _get_serializer_from_user_given_options( + zmq_serializer=zmq_serializer, + http_serializer=http_serializer ) self.instance_name = instance_name self.server_type = server_type if isinstance(server_type, bytes) else bytes(server_type, encoding='utf-8') @@ -323,13 +327,13 @@ def parse_client_message(self, message : typing.List[bytes]) -> typing.List[typi if message_type == INSTRUCTION: client_type = message[CM_INDEX_CLIENT_TYPE] if client_type == PROXY: - message[CM_INDEX_INSTRUCTION] = self.rpc_serializer.loads(message[CM_INDEX_INSTRUCTION]) # type: ignore - message[CM_INDEX_ARGUMENTS] = self.rpc_serializer.loads(message[CM_INDEX_ARGUMENTS]) # type: ignore - message[CM_INDEX_EXECUTION_CONTEXT] = self.rpc_serializer.loads(message[CM_INDEX_EXECUTION_CONTEXT]) # type: ignore + message[CM_INDEX_INSTRUCTION] = self.zmq_serializer.loads(message[CM_INDEX_INSTRUCTION]) # type: ignore + message[CM_INDEX_ARGUMENTS] = self.zmq_serializer.loads(message[CM_INDEX_ARGUMENTS]) # type: ignore + message[CM_INDEX_EXECUTION_CONTEXT] = self.zmq_serializer.loads(message[CM_INDEX_EXECUTION_CONTEXT]) # type: ignore elif client_type == HTTP_SERVER: - message[CM_INDEX_INSTRUCTION] = self.json_serializer.loads(message[CM_INDEX_INSTRUCTION]) # type: ignore - message[CM_INDEX_ARGUMENTS] = self.json_serializer.loads(message[CM_INDEX_ARGUMENTS]) # type: ignore - message[CM_INDEX_EXECUTION_CONTEXT] = self.json_serializer.loads(message[CM_INDEX_EXECUTION_CONTEXT]) # type: ignore + message[CM_INDEX_INSTRUCTION] = self.http_serializer.loads(message[CM_INDEX_INSTRUCTION]) # type: ignore + message[CM_INDEX_ARGUMENTS] = self.http_serializer.loads(message[CM_INDEX_ARGUMENTS]) # type: ignore + message[CM_INDEX_EXECUTION_CONTEXT] = self.http_serializer.loads(message[CM_INDEX_EXECUTION_CONTEXT]) # type: ignore return message elif message_type == HANDSHAKE: self.handshake(message) @@ -365,9 +369,9 @@ def craft_reply_from_arguments(self, address : bytes, client_type: bytes, messag the crafted reply with information in the correct positions within the list """ if client_type == HTTP_SERVER: - data = self.json_serializer.dumps(data) + data = self.http_serializer.dumps(data) elif client_type == PROXY: - data = self.rpc_serializer.dumps(data) + data = self.zmq_serializer.dumps(data) return [ address, @@ -405,9 +409,9 @@ def craft_reply_from_client_message(self, original_client_message : typing.List[ """ client_type = original_client_message[CM_INDEX_CLIENT_TYPE] if client_type == HTTP_SERVER: - data = self.json_serializer.dumps(data) + data = self.http_serializer.dumps(data) elif client_type == PROXY: - data = self.rpc_serializer.dumps(data) + data = self.zmq_serializer.dumps(data) else: raise ValueError(f"invalid client type given '{client_type}' for preparing message to send from " + f"'{self.identity}' of type {self.__class__}.") @@ -627,14 +631,14 @@ def exit(self) -> None: self.socket.close(0) self.logger.info(f"terminated socket of server '{self.identity}' of type {self.__class__}") except Exception as ex: - self.logger.warn("could not properly terminate socket or attempted to terminate an already terminated " + + self.logger.warning("could not properly terminate socket or attempted to terminate an already terminated " + f" socket '{self.identity}' of type {self.__class__}. Exception message : {str(ex)}") try: if self._terminate_context: self.context.term() self.logger.info("terminated context of socket '{}' of type '{}'".format(self.identity, self.__class__)) except Exception as ex: - self.logger.warn("could not properly terminate context or attempted to terminate an already terminated " + + self.logger.warning("could not properly terminate context or attempted to terminate an already terminated " + f" context '{self.identity}'. Exception message : {str(ex)}") @@ -662,9 +666,9 @@ class AsyncPollingZMQServer(AsyncZMQServer): where the max delay to stop polling will be ``poll_timeout`` **kwargs: - json_serializer: hololinked.server.serializers.JSONSerializer + http_serializer: hololinked.server.serializers.JSONSerializer serializer used to send message to HTTP Server - rpc_serializer: any of hololinked.server.serializers.serializer, default serpent + zmq_serializer: any of hololinked.server.serializers.serializer, default serpent serializer used to send message to RPC clients """ @@ -732,7 +736,7 @@ def exit(self) -> None: BaseZMQ.exit(self) self.poller.unregister(self.socket) except Exception as ex: - self.logger.warn(f"could not unregister socket {self.identity} from polling - {str(ex)}") + self.logger.warning(f"could not unregister socket {self.identity} from polling - {str(ex)}") return super().exit() @@ -868,12 +872,12 @@ def exit(self) -> None: self.poller.unregister(server.socket) server.exit() except Exception as ex: - self.logger.warn(f"could not unregister poller and exit server {server.identity} - {str(ex)}") + self.logger.warning(f"could not unregister poller and exit server {server.identity} - {str(ex)}") try: self.context.term() self.logger.info("context terminated for {}".format(self.__class__)) except Exception as ex: - self.logger.warn("could not properly terminate context or attempted to terminate an already terminated context " + + self.logger.warning("could not properly terminate context or attempted to terminate an already terminated context " + f"'{self.identity}'. Exception message : {str(ex)}") @@ -896,6 +900,9 @@ class RPCServer(BaseZMQServer): poll_timeout: int, default 25 time in milliseconds to poll the sockets specified under ``procotols``. Useful for calling ``stop_polling()`` where the max delay to stop polling will be ``poll_timeout`` + **kwargs: + tcp_socket_address: str + address of the TCP socket, if not given, a random port is chosen """ def __init__(self, instance_name : str, *, server_type : Enum, context : typing.Union[zmq.asyncio.Context, None] = None, @@ -910,8 +917,9 @@ def __init__(self, instance_name : str, *, server_type : Enum, context : typing. protocols = [protocols] else: raise TypeError(f"unsupported protocols type : {type(protocols)}") - kwargs["json_serializer"] = self.json_serializer - kwargs["rpc_serializer"] = self.rpc_serializer + tcp_socket_address = kwargs.pop('tcp_socket_address', None) + kwargs["http_serializer"] = self.http_serializer + kwargs["zmq_serializer"] = self.zmq_serializer self.inproc_server = self.ipc_server = self.tcp_server = self.event_publisher = None event_publisher_protocol = None if self.logger is None: @@ -924,7 +932,8 @@ def __init__(self, instance_name : str, *, server_type : Enum, context : typing. # initialise every externally visible protocol if ZMQ_PROTOCOLS.TCP in protocols or "TCP" in protocols: self.tcp_server = AsyncPollingZMQServer(instance_name=instance_name, server_type=server_type, - context=self.context, protocol=ZMQ_PROTOCOLS.TCP, poll_timeout=poll_timeout, **kwargs) + context=self.context, protocol=ZMQ_PROTOCOLS.TCP, poll_timeout=poll_timeout, + socket_address=tcp_socket_address, **kwargs) self.poller.register(self.tcp_server.socket, zmq.POLLIN) event_publisher_protocol = ZMQ_PROTOCOLS.TCP if ZMQ_PROTOCOLS.IPC in protocols or "IPC" in protocols: @@ -940,8 +949,8 @@ def __init__(self, instance_name : str, *, server_type : Enum, context : typing. self.event_publisher = EventPublisher( instance_name=instance_name + '-event-pub', protocol=event_publisher_protocol, - rpc_serializer=self.rpc_serializer, - json_serializer=self.json_serializer, + zmq_serializer=self.zmq_serializer, + http_serializer=self.http_serializer, logger=self.logger ) # instruction serializing broker @@ -1003,9 +1012,9 @@ def _get_timeout_from_instruction(self, message : typing.Tuple[bytes]) -> float: """ client_type = message[CM_INDEX_CLIENT_TYPE] if client_type == PROXY: - return self.rpc_serializer.loads(message[CM_INDEX_TIMEOUT]) + return self.zmq_serializer.loads(message[CM_INDEX_TIMEOUT]) elif client_type == HTTP_SERVER: - return self.json_serializer.loads(message[CM_INDEX_TIMEOUT]) + return self.http_serializer.loads(message[CM_INDEX_TIMEOUT]) async def poll(self): @@ -1019,12 +1028,51 @@ async def poll(self): await self.inner_inproc_client.handshake_complete() if self.inproc_server: eventloop.call_soon(lambda : asyncio.create_task(self.recv_instruction(self.inproc_server))) - if self.tcp_server: - eventloop.call_soon(lambda : asyncio.create_task(self.recv_instruction(self.tcp_server))) if self.ipc_server: eventloop.call_soon(lambda : asyncio.create_task(self.recv_instruction(self.ipc_server))) + if self.tcp_server: + eventloop.call_soon(lambda : asyncio.create_task(self.recv_instruction(self.tcp_server))) + def stop_polling(self): + """ + stop polling method ``poll()`` + """ + self.stop_poll = True + self._instructions_event.set() + if self.inproc_server is not None: + def kill_inproc_server(instance_name, context, logger): + # this function does not work when written fully async - reason is unknown + try: + event_loop = asyncio.get_event_loop() + except RuntimeError: + event_loop = asyncio.new_event_loop() + asyncio.set_event_loop(event_loop) + temp_inproc_client = AsyncZMQClient(server_instance_name=instance_name, + identity=f'{self.instance_name}-inproc-killer', + context=context, client_type=PROXY, protocol=ZMQ_PROTOCOLS.INPROC, + logger=logger) + event_loop.run_until_complete(temp_inproc_client.handshake_complete()) + event_loop.run_until_complete(temp_inproc_client.socket.send_multipart(temp_inproc_client.craft_empty_message_with_type(EXIT))) + temp_inproc_client.exit() + threading.Thread(target=kill_inproc_server, args=(self.instance_name, self.context, self.logger), daemon=True).start() + if self.ipc_server is not None: + temp_client = SyncZMQClient(server_instance_name=self.instance_name, identity=f'{self.instance_name}-ipc-killer', + client_type=PROXY, protocol=ZMQ_PROTOCOLS.IPC, logger=self.logger) + temp_client.socket.send_multipart(temp_client.craft_empty_message_with_type(EXIT)) + temp_client.exit() + if self.tcp_server is not None: + socket_address = self.tcp_server.socket_address + if '/*:' in self.tcp_server.socket_address: + socket_address = self.tcp_server.socket_address.replace('*', 'localhost') + # print("TCP socket address", self.tcp_server.socket_address) + temp_client = SyncZMQClient(server_instance_name=self.instance_name, identity=f'{self.instance_name}-tcp-killer', + client_type=PROXY, protocol=ZMQ_PROTOCOLS.TCP, logger=self.logger, + socket_address=socket_address) + temp_client.socket.send_multipart(temp_client.craft_empty_message_with_type(EXIT)) + temp_client.exit() + + async def recv_instruction(self, server : AsyncZMQServer): eventloop = asyncio.get_event_loop() socket = server.socket @@ -1035,6 +1083,8 @@ async def recv_instruction(self, server : AsyncZMQServer): handshake_task = asyncio.create_task(self._handshake(original_instruction, socket)) eventloop.call_soon(lambda : handshake_task) continue + if original_instruction[CM_INDEX_MESSAGE_TYPE] == EXIT: + break timeout = self._get_timeout_from_instruction(original_instruction) ready_to_process_event = None timeout_task = None @@ -1053,6 +1103,7 @@ async def recv_instruction(self, server : AsyncZMQServer): self._instructions.append((original_instruction, ready_to_process_event, timeout_task, socket)) self._instructions_event.set() + self.logger.info(f"stopped polling for server '{server.identity}' {server.socket_address[0:3].upper() if server.socket_address[0:3] in ['ipc', 'tcp'] else 'INPROC'}") async def tunnel_message_to_things(self): @@ -1077,7 +1128,7 @@ async def tunnel_message_to_things(self): else: await self._instructions_event.wait() self._instructions_event.clear() - + self.logger.info("stopped tunneling messages to things") async def process_timeouts(self, original_client_message : typing.List, ready_to_process_event : asyncio.Event, timeout : typing.Optional[float], origin_socket : zmq.Socket) -> bool: @@ -1115,7 +1166,7 @@ def exit(self): try: self.poller.unregister(socket) except Exception as ex: - self.logger.warn(f"could not unregister socket from polling - {str(ex)}") # does not give info about socket + self.logger.warning(f"could not unregister socket from polling - {str(ex)}") # does not give info about socket try: self.inproc_server.exit() self.ipc_server.exit() @@ -1145,17 +1196,17 @@ class BaseZMQClient(BaseZMQ): client_type: str RPC or HTTP Server **kwargs: - rpc_serializer: BaseSerializer + zmq_serializer: BaseSerializer custom implementation of RPC serializer if necessary - json_serializer: JSONSerializer + http_serializer: JSONSerializer custom implementation of JSON serializer if necessary """ def __init__(self, *, server_instance_name : str, client_type : bytes, server_type : typing.Union[bytes, str, Enum] = ServerTypes.UNKNOWN_TYPE, - json_serializer : typing.Union[None, JSONSerializer] = None, - rpc_serializer : typing.Union[str, BaseSerializer, None] = None, + http_serializer : typing.Union[None, JSONSerializer] = None, + zmq_serializer : typing.Union[str, BaseSerializer, None] = None, logger : typing.Optional[logging.Logger] = None, **kwargs ) -> None: @@ -1166,9 +1217,9 @@ def __init__(self, *, if server_instance_name: self.server_address = bytes(server_instance_name, encoding='utf-8') self.instance_name = server_instance_name - self.rpc_serializer, self.json_serializer = _get_serializer_from_user_given_options( - rpc_serializer=rpc_serializer, - json_serializer=json_serializer + self.zmq_serializer, self.http_serializer = _get_serializer_from_user_given_options( + zmq_serializer=zmq_serializer, + http_serializer=http_serializer ) if isinstance(server_type, bytes): self.server_type = server_type @@ -1237,18 +1288,18 @@ def parse_server_message(self, message : typing.List[bytes], raise_client_side_e if message_type == REPLY: if deserialize: if self.client_type == HTTP_SERVER: - message[SM_INDEX_DATA] = self.json_serializer.loads(message[SM_INDEX_DATA]) # type: ignore + message[SM_INDEX_DATA] = self.http_serializer.loads(message[SM_INDEX_DATA]) # type: ignore elif self.client_type == PROXY: - message[SM_INDEX_DATA] = self.rpc_serializer.loads(message[SM_INDEX_DATA]) # type: ignore + message[SM_INDEX_DATA] = self.zmq_serializer.loads(message[SM_INDEX_DATA]) # type: ignore return message elif message_type == HANDSHAKE: self.logger.debug("""handshake messages arriving out of order are silently dropped as receiving this message means handshake was successful before. Received hanshake from {}""".format(message[0])) elif message_type == EXCEPTION or message_type == INVALID_MESSAGE: if self.client_type == HTTP_SERVER: - message[SM_INDEX_DATA] = self.json_serializer.loads(message[SM_INDEX_DATA]) # type: ignore + message[SM_INDEX_DATA] = self.http_serializer.loads(message[SM_INDEX_DATA]) # type: ignore elif self.client_type == PROXY: - message[SM_INDEX_DATA] = self.rpc_serializer.loads(message[SM_INDEX_DATA]) # type: ignore + message[SM_INDEX_DATA] = self.zmq_serializer.loads(message[SM_INDEX_DATA]) # type: ignore if not raise_client_side_exception: return message if message[SM_INDEX_DATA].get('exception', None) is not None: @@ -1278,20 +1329,20 @@ def craft_instruction_from_arguments(self, instruction : str, arguments : typing """ message_id = bytes(str(uuid4()), encoding='utf-8') if self.client_type == HTTP_SERVER: - timeout = self.json_serializer.dumps(timeout) # type: bytes - instruction = self.json_serializer.dumps(instruction) # type: bytes + timeout = self.http_serializer.dumps(timeout) # type: bytes + instruction = self.http_serializer.dumps(instruction) # type: bytes # TODO - following can be improved if arguments == b'': - arguments = self.json_serializer.dumps({}) # type: bytes + arguments = self.http_serializer.dumps({}) # type: bytes elif not isinstance(arguments, byte_types): - arguments = self.json_serializer.dumps(arguments) # type: bytes - context = self.json_serializer.dumps(context) # type: bytes + arguments = self.http_serializer.dumps(arguments) # type: bytes + context = self.http_serializer.dumps(context) # type: bytes elif self.client_type == PROXY: - timeout = self.rpc_serializer.dumps(timeout) # type: bytes - instruction = self.rpc_serializer.dumps(instruction) # type: bytes + timeout = self.zmq_serializer.dumps(timeout) # type: bytes + instruction = self.zmq_serializer.dumps(instruction) # type: bytes if not isinstance(arguments, byte_types): - arguments = self.rpc_serializer.dumps(arguments) # type: bytes - context = self.rpc_serializer.dumps(context) + arguments = self.zmq_serializer.dumps(arguments) # type: bytes + context = self.zmq_serializer.dumps(context) return [ self.server_address, @@ -1326,10 +1377,14 @@ def exit(self) -> None: BaseZMQ.exit(self) try: self.poller.unregister(self.socket) + # TODO - there is some issue here while quitting + # print("poller exception did not occur 1") if self._monitor_socket is not None: + # print("poller exception did not occur 2") self.poller.unregister(self._monitor_socket) + # print("poller exception did not occur 3") except Exception as ex: - self.logger.warn(f"unable to deregister from poller - {str(ex)}") + self.logger.warning(f"unable to deregister from poller - {str(ex)}") try: if self._monitor_socket is not None: @@ -1337,14 +1392,14 @@ def exit(self) -> None: self.socket.close(0) self.logger.info("terminated socket of server '{}' of type '{}'".format(self.identity, self.__class__)) except Exception as ex: - self.logger.warn("could not properly terminate socket or attempted to terminate an already terminated " + + self.logger.warning("could not properly terminate socket or attempted to terminate an already terminated " + f"socket '{self.identity}' of type '{self.__class__}'. Exception message : {str(ex)}") try: if self._terminate_context: self.context.term() self.logger.info("terminated context of socket '{}' of type '{}'".format(self.identity, self.__class__)) except Exception as ex: - self.logger.warn("could not properly terminate context or attempted to terminate an already terminated context" + + self.logger.warning("could not properly terminate context or attempted to terminate an already terminated context" + "'{}'. Exception message : {}".format(self.identity, str(ex))) @@ -1371,15 +1426,15 @@ class SyncZMQClient(BaseZMQClient, BaseSyncZMQ): **kwargs: socket_address: str socket address for connecting to TCP server - rpc_serializer: + zmq_serializer: custom implementation of RPC serializer if necessary - json_serializer: + http_serializer: custom implementation of JSON serializer if necessary """ def __init__(self, server_instance_name : str, identity : str, client_type = HTTP_SERVER, handshake : bool = True, protocol : str = ZMQ_PROTOCOLS.IPC, - context : typing.Union[zmq.asyncio.Context, None] = None, + context : typing.Union[zmq.Context, None] = None, **kwargs) -> None: BaseZMQClient.__init__(self, server_instance_name=server_instance_name, client_type=client_type, **kwargs) @@ -1388,6 +1443,8 @@ def __init__(self, server_instance_name : str, identity : str, client_type = HTT self.poller = zmq.Poller() self.poller.register(self.socket, zmq.POLLIN) self._terminate_context = context == None + self._client_queue = threading.RLock() + # print("context on client", self.context) if handshake: self.handshake(kwargs.pop("handshake_timeout", 60000)) @@ -1427,8 +1484,6 @@ def send_instruction(self, instruction : str, arguments : typing.Dict[str, typin a byte representation of message id """ message = self.craft_instruction_from_arguments(instruction, arguments, invokation_timeout, context) - # if argument_schema: - # jsonschema.validate(arguments, argument_schema) self.socket.send_multipart(message) self.logger.debug(f"sent instruction '{instruction}' to server '{self.instance_name}' with msg-id '{message[SM_INDEX_MESSAGE_ID]}'") return message[SM_INDEX_MESSAGE_ID] @@ -1487,9 +1542,13 @@ def execute(self, instruction : str, arguments : typing.Dict[str, typing.Any] = message id : bytes a byte representation of message id """ - msg_id = self.send_instruction(instruction, arguments, invokation_timeout, execution_timeout, context, argument_schema) - return self.recv_reply(msg_id, raise_client_side_exception=raise_client_side_exception, deserialize=deserialize_reply) - + try: + self._client_queue.acquire() + msg_id = self.send_instruction(instruction, arguments, invokation_timeout, execution_timeout, context, argument_schema) + return self.recv_reply(msg_id, raise_client_side_exception=raise_client_side_exception, deserialize=deserialize_reply) + finally: + self._client_queue.release() + def handshake(self, timeout : typing.Union[float, int] = 60000) -> None: """ @@ -1539,6 +1598,7 @@ def __init__(self, server_instance_name : str, identity : str, client_type = HTT self._terminate_context = context == None self._handshake_event = asyncio.Event() self._handshake_event.clear() + self._client_queue = asyncio.Lock() if handshake: self.handshake(kwargs.pop("handshake_timeout", 60000)) @@ -1576,10 +1636,11 @@ async def _handshake(self, timeout : typing.Union[float, int] = 60000) -> None: raise ConnectionAbortedError(f"Handshake cannot be done with '{self.instance_name}'. Another message arrived before handshake complete.") else: self.logger.info('got no reply') - self._handshake_event.set() self._monitor_socket = self.socket.get_monitor_socket() self.poller.register(self._monitor_socket, zmq.POLLIN) - + self._handshake_event.set() + + async def handshake_complete(self): """ wait for handshake to complete @@ -1621,8 +1682,6 @@ async def async_send_instruction(self, instruction : str, arguments : typing.Dic a byte representation of message id """ message = self.craft_instruction_from_arguments(instruction, arguments, invokation_timeout, context) - # if argument_schema: - # jsonschema.validate(arguments, argument_schema) await self.socket.send_multipart(message) self.logger.debug(f"sent instruction '{instruction}' to server '{self.instance_name}' with msg-id {message[SM_INDEX_MESSAGE_ID]}") return message[SM_INDEX_MESSAGE_ID] @@ -1682,13 +1741,17 @@ async def async_execute(self, instruction : str, arguments : typing.Dict[str, ty message id : bytes a byte representation of message id """ - msg_id = await self.async_send_instruction(instruction, arguments, invokation_timeout, execution_timeout, - context, argument_schema) - return await self.async_recv_reply(msg_id, raise_client_side_exception=raise_client_side_exception, - deserialize=deserialize_reply) - - + try: + await self._client_queue.acquire() + msg_id = await self.async_send_instruction(instruction, arguments, invokation_timeout, execution_timeout, + context, argument_schema) + return await self.async_recv_reply(msg_id, raise_client_side_exception=raise_client_side_exception, + deserialize=deserialize_reply) + finally: + self._client_queue.release() + + class MessageMappedZMQClientPool(BaseZMQClient): """ Pool of clients where message ID can track the replies irrespective of order of arrival. @@ -1708,7 +1771,7 @@ def __init__(self, server_instance_names: typing.List[str], identity: str, clien for instance_name in server_instance_names: client = AsyncZMQClient(server_instance_name=instance_name, identity=identity, client_type=client_type, handshake=handshake, protocol=protocol, - context=self.context, rpc_serializer=self.rpc_serializer, json_serializer=self.json_serializer, + context=self.context, zmq_serializer=self.zmq_serializer, http_serializer=self.http_serializer, logger=self.logger) client._monitor_socket = client.socket.get_monitor_socket() self.poller.register(client._monitor_socket, zmq.POLLIN) @@ -1737,7 +1800,7 @@ def create_new(self, server_instance_name : str, protocol : str = 'IPC') -> None if server_instance_name not in self.pool.keys(): client = AsyncZMQClient(server_instance_name=server_instance_name, identity=self.identity, client_type=self.client_type, handshake=True, protocol=protocol, - context=self.context, rpc_serializer=self.rpc_serializer, json_serializer=self.json_serializer, + context=self.context, zmq_serializer=self.zmq_serializer, http_serializer=self.http_serializer, logger=self.logger) client._monitor_socket = client.socket.get_monitor_socket() self.poller.register(client._monitor_socket, zmq.POLLIN) @@ -1979,7 +2042,7 @@ def start_polling(self) -> None: event_loop = asyncio.get_event_loop() event_loop.call_soon(lambda: asyncio.create_task(self.poll())) - async def stop_polling(self): + def stop_polling(self): """ stop polling for replies from server """ @@ -2054,7 +2117,7 @@ def exit(self) -> None: self.context.term() self.logger.info("context terminated for '{}'".format(self.__class__)) except Exception as ex: - self.logger.warn("could not properly terminate context or attempted to terminate an already terminated context" + + self.logger.warning("could not properly terminate context or attempted to terminate an already terminated context" + "'{}'. Exception message : {}".format(self.identity, str(ex))) @@ -2101,10 +2164,10 @@ def __init__(self, instance_name : str, protocol : str, self.create_socket(identity=f'{instance_name}/event-publisher', bind=True, context=context, protocol=protocol, socket_type=zmq.PUB, **kwargs) self.logger.info(f"created event publishing socket at {self.socket_address}") - self.events = set() # type: typing.Set[Event] + self.events = set() # type: typing.Set[EventDispatcher] self.event_ids = set() # type: typing.Set[bytes] - def register(self, event : "Event") -> None: + def register(self, event : "EventDispatcher") -> None: """ register event with a specific (unique) name @@ -2115,12 +2178,12 @@ def register(self, event : "Event") -> None: automatically registered. """ if event._unique_identifier in self.events and event not in self.events: - raise AttributeError(f"event {event.name} already found in list of events, please use another name.") + raise AttributeError(f"event {event._name} already found in list of events, please use another name.") self.event_ids.add(event._unique_identifier) self.events.add(event) - self.logger.info("registered event '{}' serving at PUB socket with address : {}".format(event.name, self.socket_address)) + - def publish(self, unique_identifier : bytes, data : typing.Any, *, rpc_clients : bool = True, + def publish(self, unique_identifier : bytes, data : typing.Any, *, zmq_clients : bool = True, http_clients : bool = True, serialize : bool = True) -> None: """ publish an event with given unique name. @@ -2133,20 +2196,21 @@ def publish(self, unique_identifier : bytes, data : typing.Any, *, rpc_clients : payload of the event serialize: bool, default True serialize the payload before pushing, set to False when supplying raw bytes - rpc_clients: bool, default True + zmq_clients: bool, default True pushes event to RPC clients http_clients: bool, default True pushed event to HTTP clients """ if unique_identifier in self.event_ids: if serialize: - if isinstance(self.rpc_serializer , JSONSerializer): - self.socket.send_multipart([unique_identifier, self.json_serializer.dumps(data)]) + if isinstance(self.zmq_serializer , JSONSerializer): + self.socket.send_multipart([unique_identifier, self.http_serializer.dumps(data)]) return - if rpc_clients: - self.socket.send_multipart([unique_identifier, self.rpc_serializer.dumps(data)]) + if zmq_clients: + # TODO - event id should not any longer be unique + self.socket.send_multipart([unique_identifier, self.zmq_serializer.dumps(data)]) if http_clients: - self.socket.send_multipart([unique_identifier, self.json_serializer.dumps(data)]) + self.socket.send_multipart([unique_identifier, self.http_serializer.dumps(data)]) else: self.socket.send_multipart([unique_identifier, data]) else: @@ -2159,13 +2223,13 @@ def exit(self): self.socket.close(0) self.logger.info("terminated event publishing socket with address '{}'".format(self.socket_address)) except Exception as E: - self.logger.warn("could not properly terminate context or attempted to terminate an already terminated context at address '{}'. Exception message : {}".format( + self.logger.warning("could not properly terminate context or attempted to terminate an already terminated context at address '{}'. Exception message : {}".format( self.socket_address, str(E))) try: self.context.term() self.logger.info("terminated context of event publishing socket with address '{}'".format(self.socket_address)) except Exception as E: - self.logger.warn("could not properly terminate socket or attempted to terminate an already terminated socket of event publishing socket at address '{}'. Exception message : {}".format( + self.logger.warning("could not properly terminate socket or attempted to terminate an already terminated socket of event publishing socket at address '{}'. Exception message : {}".format( self.socket_address, str(E))) @@ -2187,9 +2251,9 @@ class BaseEventConsumer(BaseZMQClient): **kwargs: protocol: str TCP, IPC or INPROC - json_serializer: JSONSerializer + http_serializer: JSONSerializer json serializer instance for HTTP_SERVER client type - rpc_serializer: BaseSerializer + zmq_serializer: BaseSerializer serializer for RPC clients server_instance_name: str instance name of the Thing publishing the event @@ -2224,7 +2288,7 @@ def exit(self): self.poller.unregister(self.socket) self.poller.unregister(self.interruptor) except Exception as E: - self.logger.warn("could not properly terminate socket or attempted to terminate an already terminated socket of event consuming socket at address '{}'. Exception message : {}".format( + self.logger.warning("could not properly terminate socket or attempted to terminate an already terminated socket of event consuming socket at address '{}'. Exception message : {}".format( self.socket_address, str(E))) try: @@ -2233,14 +2297,14 @@ def exit(self): self.interrupting_peer.close(0) self.logger.info("terminated event consuming socket with address '{}'".format(self.socket_address)) except: - self.logger.warn("could not terminate sockets") + self.logger.warning("could not terminate sockets") try: if self._terminate_context: self.context.term() self.logger.info("terminated context of event consuming socket with address '{}'".format(self.socket_address)) except Exception as E: - self.logger.warn("could not properly terminate context or attempted to terminate an already terminated context at address '{}'. Exception message : {}".format( + self.logger.warning("could not properly terminate context or attempted to terminate an already terminated context at address '{}'. Exception message : {}".format( self.socket_address, str(E))) @@ -2262,9 +2326,9 @@ class AsyncEventConsumer(BaseEventConsumer): **kwargs: protocol: str TCP, IPC or INPROC - json_serializer: JSONSerializer + http_serializer: JSONSerializer json serializer instance for HTTP_SERVER client type - rpc_serializer: BaseSerializer + zmq_serializer: BaseSerializer serializer for RPC clients server_instance_name: str instance name of the Thing publishing the event @@ -2304,9 +2368,9 @@ async def receive(self, timeout : typing.Optional[float] = None, deserialize = T if not deserialize or not contents: return contents if self.client_type == HTTP_SERVER: - return self.json_serializer.loads(contents) + return self.http_serializer.loads(contents) elif self.client_type == PROXY: - return self.rpc_serializer.loads(contents) + return self.zmq_serializer.loads(contents) else: raise ValueError("invalid client type") @@ -2316,11 +2380,11 @@ async def interrupt(self): generally should be used for exiting this object """ if self.client_type == HTTP_SERVER: - message = [self.json_serializer.dumps(f'{self.identity}/interrupting-server'), - self.json_serializer.dumps("INTERRUPT")] + message = [self.http_serializer.dumps(f'{self.identity}/interrupting-server'), + self.http_serializer.dumps("INTERRUPT")] elif self.client_type == PROXY: - message = [self.rpc_serializer.dumps(f'{self.identity}/interrupting-server'), - self.rpc_serializer.dumps("INTERRUPT")] + message = [self.zmq_serializer.dumps(f'{self.identity}/interrupting-server'), + self.zmq_serializer.dumps("INTERRUPT")] await self.interrupting_peer.send_multipart(message) @@ -2341,9 +2405,9 @@ class EventConsumer(BaseEventConsumer): **kwargs: protocol: str TCP, IPC or INPROC - json_serializer: JSONSerializer + http_serializer: JSONSerializer json serializer instance for HTTP_SERVER client type - rpc_serializer: BaseSerializer + zmq_serializer: BaseSerializer serializer for RPC clients server_instance_name: str instance name of the Thing publishing the event @@ -2383,9 +2447,9 @@ def receive(self, timeout : typing.Optional[float] = None, deserialize = True) - if not deserialize: return contents if self.client_type == HTTP_SERVER: - return self.json_serializer.loads(contents) + return self.http_serializer.loads(contents) elif self.client_type == PROXY: - return self.rpc_serializer.loads(contents) + return self.zmq_serializer.loads(contents) else: raise ValueError("invalid client type for event") @@ -2395,16 +2459,16 @@ def interrupt(self): generally should be used for exiting this object """ if self.client_type == HTTP_SERVER: - message = [self.json_serializer.dumps(f'{self.identity}/interrupting-server'), - self.json_serializer.dumps("INTERRUPT")] + message = [self.http_serializer.dumps(f'{self.identity}/interrupting-server'), + self.http_serializer.dumps("INTERRUPT")] elif self.client_type == PROXY: - message = [self.rpc_serializer.dumps(f'{self.identity}/interrupting-server'), - self.rpc_serializer.dumps("INTERRUPT")] + message = [self.zmq_serializer.dumps(f'{self.identity}/interrupting-server'), + self.zmq_serializer.dumps("INTERRUPT")] self.interrupting_peer.send_multipart(message) -from .events import Event +from .events import EventDispatcher __all__ = [ diff --git a/hololinked/system_host/__init__.py b/hololinked/system_host/__init__.py deleted file mode 100644 index 134ebb0..0000000 --- a/hololinked/system_host/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .server import create_system_host \ No newline at end of file diff --git a/hololinked/system_host/assets/default_host_settings.json b/hololinked/system_host/assets/default_host_settings.json deleted file mode 100644 index 13a662f..0000000 --- a/hololinked/system_host/assets/default_host_settings.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "dashboards" : { - "deleteWithoutAsking" : true, - "showRecentlyUsed" : true, - "use" : true - }, - "login" : { - "footer" : "", - "footerLink" : "", - "displayFooter" : true - }, - "servers" : { - "allowHTTP" : false - }, - "remoteObjectViewer" : { - "console" : { - "stringifyOutput" : false, - "defaultMaxEntries" : 15, - "defaultWindowSize" : 500, - "defaultFontSize" : 16 - }, - "logViewer" : { - "defaultMaxEntries" : 10, - "defaultWindowSize" : 500, - "defaultFontSize" : 16, - "defaultInterval" : 2 - } - }, - "others" : { - "WOTTerminology" : false - } -} \ No newline at end of file diff --git a/hololinked/system_host/assets/hololinked-server-swagger-api b/hololinked/system_host/assets/hololinked-server-swagger-api deleted file mode 160000 index 96e9aa8..0000000 --- a/hololinked/system_host/assets/hololinked-server-swagger-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 96e9aa86a7f60df6ae5b8dbf7f9d049b64b6464d diff --git a/hololinked/system_host/assets/swagger_ui_template.html b/hololinked/system_host/assets/swagger_ui_template.html deleted file mode 100644 index fb7dc11..0000000 --- a/hololinked/system_host/assets/swagger_ui_template.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - Swagger UI - - - -
- - - - diff --git a/hololinked/system_host/assets/system_host_api.yml b/hololinked/system_host/assets/system_host_api.yml deleted file mode 100644 index b3e30a5..0000000 --- a/hololinked/system_host/assets/system_host_api.yml +++ /dev/null @@ -1,12 +0,0 @@ -openapi: '3.0.2' -info: - title: API Title - version: '1.0' -servers: - - url: https://api.server.test/v1 -paths: - /test: - get: - responses: - '200': - description: OK diff --git a/hololinked/system_host/handlers.py b/hololinked/system_host/handlers.py deleted file mode 100644 index cc25293..0000000 --- a/hololinked/system_host/handlers.py +++ /dev/null @@ -1,569 +0,0 @@ -import os -import socket -import uuid -import typing -import copy -from typing import List -from argon2 import PasswordHasher - -from sqlalchemy import select, delete, update -from sqlalchemy.orm import Session -from sqlalchemy.ext import asyncio as asyncio_ext -from sqlalchemy.exc import SQLAlchemyError -from tornado.web import RequestHandler, HTTPError, authenticated - - -from .models import * -from ..server.serializers import JSONSerializer -from ..server.config import global_config -from ..server.utils import get_IP_from_interface - - -def for_authenticated_user(method): - async def authenticated_method(self : "SystemHostHandler", *args, **kwargs) -> None: - if self.current_user_valid: - return await method(self, *args, **kwargs) - self.set_status(403) - self.set_custom_default_headers() - self.finish() - return - return authenticated_method - - -class SystemHostHandler(RequestHandler): - """ - Base Request Handler for all requests directed to system host server. Implements CORS & credential checks. - Use built in swagger-ui for request handler documentation for other paths. - """ - - def initialize(self, CORS : typing.List[str], disk_session : Session, mem_session : asyncio_ext.AsyncSession) -> None: - self.CORS = CORS - self.disk_session = disk_session - self.mem_session = mem_session - - @property - def headers_ok(self): - """ - check suitable values for headers before processing the request - """ - content_type = self.request.headers.get("Content-Type", None) - if content_type and content_type != "application/json": - self.set_status(400, "request body is not JSON.") - self.finish() - return False - return True - - @property - def current_user_valid(self) -> bool: - """ - check if current user is a valid user for accessing authenticated resources - """ - user = self.get_signed_cookie('user', None) - if user is None: - return False - with self.mem_session() as session: - session : Session - stmt = select(UserSession).filter_by(session_key=user) - data = session.execute(stmt) - data = data.scalars().all() - if len(data) == 0: - return False - if len(data) > 1: - raise HTTPError("session ID not unique, internal logic error - contact developers (https://github.com/VigneshVSV/hololinked/issues)") - data = data[0] - if (data.session_key == user and data.origin == self.request.headers.get("Origin") and - data.user_agent == self.request.headers.get("User-Agent") and data.remote_IP == self.request.remote_ip): - return True - - def get_current_user(self) -> typing.Any: - """ - gets the current logged in user - call after ``current_user_valid`` - """ - return self.get_signed_cookie('user', None) - - def set_access_control_allow_origin(self) -> None: - """ - For credential login, access control allow origin cannot be '*', - See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#examples_of_access_control_scenarios - """ - origin = self.request.headers.get("Origin") - if origin is not None and (origin in self.CORS or origin + '/' in self.CORS): - self.set_header("Access-Control-Allow-Origin", origin) - - def set_access_control_allow_headers(self) -> None: - """ - For credential login, access control allow headers cannot be '*'. - See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#examples_of_access_control_scenarios - """ - headers = ", ".join(self.request.headers.keys()) - if self.request.headers.get("Access-Control-Request-Headers", None): - headers += ", " + self.request.headers["Access-Control-Request-Headers"] - self.set_header("Access-Control-Allow-Headers", headers) - - def set_access_control_allow_methods(self) -> None: - """ - sets methods allowed so that options method can be reused in all children - """ - self.set_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - - def set_custom_default_headers(self) -> None: - """ - sets access control allow origin, allow headers and allow credentials - """ - self.set_access_control_allow_origin() - self.set_access_control_allow_headers() - self.set_header("Access-Control-Allow-Credentials", "true") - - async def options(self): - self.set_status(204) - self.set_access_control_allow_methods() - self.set_custom_default_headers() - self.finish() - - -class UsersHandler(SystemHostHandler): - - async def post(self): - self.set_status(200) - self.finish() - - async def get(self): - self.set_status(200) - self.finish() - - -class LoginHandler(SystemHostHandler): - """ - performs login and supplies a signed cookie for session - """ - async def post(self): - if not self.headers_ok: - return - try: - body = JSONSerializer.generic_loads(self.request.body) - email = body["email"] - password = body["password"] - rememberme = body["rememberme"] - async with self.disk_session() as session: - session : asyncio_ext.AsyncSession - stmt = select(LoginCredentials).filter_by(email=email) - data = await session.execute(stmt) - data = data.scalars().all() # type: typing.List[LoginCredentials] - if len(data) == 0: - self.set_status(404, "authentication failed - username not found") - else: - data = data[0] # type: LoginCredentials - ph = PasswordHasher(time_cost=global_config.PWD_HASHER_TIME_COST) - if ph.verify(data.password, password): - self.set_status(204, "logged in") - cookie_value = bytes(str(uuid.uuid4()), encoding = 'utf-8') - self.set_signed_cookie("user", cookie_value, httponly=True, - secure=True, samesite="strict", - expires_days=30 if rememberme else None) - with self.mem_session() as session: - session : Session - session.add(UserSession(email=email, session_key=cookie_value, - origin=self.request.headers.get("Origin"), - user_agent=self.request.headers.get("User-Agent"), - remote_IP=self.request.remote_ip - ) - ) - session.commit() - except Exception as ex: - ex_str = str(ex) - if ex_str.startswith("password does not match"): - ex_str = "username or password not correct" - self.set_status(500, f"authentication failed - {ex_str}") - self.set_custom_default_headers() - self.finish() - - def set_access_control_allow_methods(self) -> None: - self.set_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - - -class LogoutHandler(SystemHostHandler): - """ - Performs logout and clears the signed cookie of session - """ - - @for_authenticated_user - async def post(self): - if not self.headers_ok: - return - try: - if not self.current_user_valid: - self.set_status(409, "not a valid user to logout") - else: - user = self.get_current_user() - with self.mem_session() as session: - session : Session - stmt = delete(UserSession).filter_by(session_key=user) - result = session.execute(stmt) - if result.rowcount != 1: - self.set_status(500, "found user but could not logout") # never comes here - session.commit() - self.set_status(204, "logged out") - self.clear_cookie("user") - except Exception as ex: - self.set_status(500, f"logout failed - {str(ex)}") - self.set_custom_default_headers() - self.finish() - - def set_access_control_allow_methods(self) -> None: - self.set_header("Access-Control-Allow-Methods", "POST, OPTIONS") - - -class WhoAmIHandler(SystemHostHandler): - - @for_authenticated_user - async def get(self): - - with self.mem_session() as session: - session : Session - stmt = select(UserSession).filter_by(session_key=user) - data = session.execute(stmt) - data = data.scalars().all() - - user = self.get_current_user() - with self.mem_session() as session: - session : Session - stmt = delete(UserSession).filter_by(session_key=user) - result = session.execute(stmt) - if result.rowcount != 1: - self.set_status(500, "found user but could not logout") # never comes here - session.commit() - self.set_status(204, "logged out") - self.clear_cookie("user") - - - -class AppSettingsHandler(SystemHostHandler): - - @for_authenticated_user - async def get(self, field : typing.Optional[str] = None): - if not self.headers_ok: - return - try: - async with self.disk_session() as session: - session : asyncio_ext.AsyncSession - stmt = select(AppSettings) - data = await session.execute(stmt) - serialized_data = JSONSerializer.generic_dumps({ - result[AppSettings.__name__].field : result[AppSettings.__name__].value - for result in data.mappings().all()}) - self.set_status(200) - self.set_header("Content-Type", "application/json") - self.write(serialized_data) - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - async def options(self, name : typing.Optional[str] = None): - self.set_status(204) - self.set_header("Access-Control-Allow-Methods", "GET, OPTIONS") - self.set_custom_default_headers() - self.finish() - - -class AppSettingHandler(SystemHostHandler): - - @for_authenticated_user - async def post(self): - if not self.headers_ok: - return - try: - value = JSONSerializer.generic_loads(self.request.body["value"]) - async with self.disk_session() as session: - session : asyncio_ext.AsyncSession - session.add(AppSettings( - field = field, - value = {"value" : value} - )) - await session.commit() - self.set_status(200) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - @for_authenticated_user - async def patch(self, field : str): - if not self.headers_ok: - return - try: - value = JSONSerializer.generic_loads(self.request.body) - if field == 'remote-object-viewer': - field = 'remoteObjectViewer' - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.AsyncSession - stmt = select(AppSettings).filter_by(field=field) - data = await session.execute(stmt) - setting : AppSettings = data.scalar() - new_value = copy.deepcopy(setting.value) - self.deepupdate_dict(new_value, value) - setting.value = new_value - await session.commit() - self.set_status(200) - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - async def options(self, name : typing.Optional[str] = None): - self.set_status(204) - self.set_header("Access-Control-Allow-Methods", "POST, PATCH, OPTIONS") - self.set_custom_default_headers() - self.finish() - - def deepupdate_dict(self, d : dict, u : dict): - for k, v in u.items(): - if isinstance(v, dict): - d[k] = self.deepupdate_dict(d.get(k, {}), v) - else: - d[k] = v - return d - - -class PagesHandler(SystemHostHandler): - """ - get all pages - endpoint /pages - """ - - @for_authenticated_user - async def get(self): - if not self.headers_ok: - return - try: - async with self.disk_session() as session: - session : asyncio_ext.AsyncSession - stmt = select(Pages) - data = await session.execute(stmt) - serialized_data = JSONSerializer.generic_dumps([result[Pages.__name__].json() for result - in data.mappings().all()]) - self.set_status(200) - self.set_header("Content-Type", "application/json") - self.write(serialized_data) - except SQLAlchemyError as ex: - self.set_status(500, "database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - -class PageHandler(SystemHostHandler): - """ - add or edit a single page. endpoint - /pages/{name} - """ - - @for_authenticated_user - async def post(self, name): - if not self.headers_ok: - return - try: - data = JSONSerializer.generic_loads(self.request.body) - # name = self.request.arguments - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.AsyncSession - session.add(Pages(name=name, **data)) - await session.commit() - self.set_status(201) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - @for_authenticated_user - async def put(self, name): - if not self.headers_ok: - return - try: - updated_data = JSONSerializer.generic_loads(self.request.body) - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.AsyncSession - stmt = select(Pages).filter_by(name=name) - page = (await session.execute(stmt)).mappings().all() - if(len(page) == 0): - self.set_status(404, f"no such page with given name {name}") - else: - existing_data = page[0][Pages.__name__] # type: Pages - if updated_data.get("description", None): - existing_data.description = updated_data["description"] - if updated_data.get("URL", None): - existing_data.URL = updated_data["URL"] - await session.commit() - self.set_status(204) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - - @for_authenticated_user - async def delete(self, name): - if not self.headers_ok: - return - try: - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.AsyncSession - stmt = delete(Pages).filter_by(name=name) - ret = await session.execute(stmt) - await session.commit() - self.set_status(204) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - async def options(self, name): - print("name is ", name) - self.set_status(204) - self.set_header("Access-Control-Allow-Methods", "POST, PUT, DELETE, OPTIONS") - self.set_custom_default_headers() - self.finish() - - -class SubscribersHandler(SystemHostHandler): - - async def post(self): - if not self.headers_ok: - return - try: - server = Server(**JSONSerializer.generic_loads(self.request.body)) - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.AsyncSession - session.add(server) - await session.commit() - self.set_status(201) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - async def get(self): - if not self.headers_ok: - return - try: - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.session - stmt = select(Server) - result = await session.execute(stmt) - serialized_data = JSONSerializer.generic_dumps([val[Server.__name__].json() for val in result.mappings().all()]) - self.set_status(200) - self.set_header("Content-Type", "application/json") - self.write(serialized_data) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - async def put(self): - if not self.headers_ok: - return - try: - server = JSONSerializable.loads(self.request.body) - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.session - stmt = select(Server).filter_by(name=server.get("name", None)) - result = (await session.execute(stmt)).mappings().all() - if len(result) == 0: - self.set_status(404) - else: - result = result[0][Server.__name__] # type: Server - await session.commit() - self.set_status(204) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - async def delete(self): - if not self.headers_ok: - return - try: - server = JSONSerializable.loads(self.request.body) - async with self.disk_session() as session, session.begin(): - session : asyncio_ext.session - stmt = delete(Server).filter_by(name=server.get("name", None)) - ret = await session.execute(stmt) - self.set_status(204) - except SQLAlchemyError as ex: - self.set_status(500, "Database error - check message on server") - except Exception as ex: - self.set_status(500, str(ex)) - self.set_custom_default_headers() - self.finish() - - def set_access_control_allow_methods(self) -> None: - self.set_header("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS") - - -class SubscriberHandler(SystemHostHandler): - - async def get(self): - pass - - -class SwaggerUIHandler(SystemHostHandler): - - async def get(self): - await self.render( - f"{os.path.dirname(os.path.abspath(__file__))}{os.sep}assets{os.sep}swagger_ui_template.html", - swagger_spec_url="/index.yml" - ) - - -class MainHandler(SystemHostHandler): - - def initialize(self, CORS: List[str], disk_session: Session, - mem_session: asyncio_ext.AsyncSession, swagger : bool = False, - IP : str = "") -> None: - self.swagger = swagger - self.IP = IP - return super().initialize(CORS, disk_session, mem_session) - - async def get(self): - if not self.headers_ok: - return - self.set_status(200) - self.set_custom_default_headers() - self.write("

I am alive!

") - if self.swagger: - self.write(f"

Visit here to login and use my swagger doc

") - self.finish() - - - - - - - -__all__ = [ - SystemHostHandler.__name__, - UsersHandler.__name__, - AppSettingsHandler.__name__, - AppSettingHandler.__name__, - LoginHandler.__name__, - LogoutHandler.__name__, - WhoAmIHandler.__name__, - PagesHandler.__name__, - PageHandler.__name__, - SubscribersHandler.__name__, - SwaggerUIHandler.__name__, - MainHandler.__name__ -] \ No newline at end of file diff --git a/hololinked/system_host/models.py b/hololinked/system_host/models.py deleted file mode 100644 index 14d7948..0000000 --- a/hololinked/system_host/models.py +++ /dev/null @@ -1,86 +0,0 @@ -import typing -from dataclasses import asdict, field - -from sqlalchemy import Integer, String, JSON, ARRAY, Boolean, BLOB -from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, MappedAsDataclass - -from ..server.constants import JSONSerializable - - -class HololinkedHostTableBase(DeclarativeBase): - pass - -class Pages(HololinkedHostTableBase, MappedAsDataclass): - __tablename__ = "pages" - - name : Mapped[str] = mapped_column(String(1024), primary_key=True, nullable=False) - URL : Mapped[str] = mapped_column(String(1024), unique=True, nullable=False) - description : Mapped[str] = mapped_column(String(16384)) - json_specfication : Mapped[typing.Dict[str, typing.Any]] = mapped_column(JSON, nullable=True) - - def json(self): - return { - "name" : self.name, - "URL" : self.URL, - "description" : self.description, - "json_specification" : self.json_specfication - } - -class AppSettings(HololinkedHostTableBase, MappedAsDataclass): - __tablename__ = "appsettings" - - field : Mapped[str] = mapped_column(String(8192), primary_key=True) - value : Mapped[typing.Dict[str, typing.Any]] = mapped_column(JSON) - - def json(self): - return asdict(self) - -class LoginCredentials(HololinkedHostTableBase, MappedAsDataclass): - __tablename__ = "login_credentials" - - email : Mapped[str] = mapped_column(String(1024), primary_key=True) - password : Mapped[str] = mapped_column(String(1024), unique=True) - -class Server(HololinkedHostTableBase, MappedAsDataclass): - __tablename__ = "http_servers" - - hostname : Mapped[str] = mapped_column(String, primary_key=True) - type : Mapped[str] = mapped_column(String) - port : Mapped[int] = mapped_column(Integer) - IPAddress : Mapped[str] = mapped_column(String) - https : Mapped[bool] = mapped_column(Boolean) - - def json(self): - return { - "hostname" : self.hostname, - "type" : self.type, - "port" : self.port, - "IPAddress" : self.IPAddress, - "https" : self.https - } - - - -class HololinkedHostInMemoryTableBase(DeclarativeBase): - pass - -class UserSession(HololinkedHostInMemoryTableBase, MappedAsDataclass): - __tablename__ = "user_sessions" - - email : Mapped[str] = mapped_column(String) - session_key : Mapped[BLOB] = mapped_column(BLOB, primary_key=True) - origin : Mapped[str] = mapped_column(String) - user_agent : Mapped[str] = mapped_column(String) - remote_IP : Mapped[str] = mapped_column(String) - - - -__all__ = [ - HololinkedHostTableBase.__name__, - HololinkedHostInMemoryTableBase.__name__, - Pages.__name__, - AppSettings.__name__, - LoginCredentials.__name__, - Server.__name__, - UserSession.__name__ -] \ No newline at end of file diff --git a/hololinked/system_host/server.py b/hololinked/system_host/server.py deleted file mode 100644 index f229abb..0000000 --- a/hololinked/system_host/server.py +++ /dev/null @@ -1,146 +0,0 @@ -import secrets -import os -import base64 -import socket -import json -import asyncio -import ssl -import typing -import getpass -from argon2 import PasswordHasher - -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker -from sqlalchemy.ext import asyncio as asyncio_ext -from sqlalchemy_utils import database_exists, create_database, drop_database -from tornado.web import Application, StaticFileHandler, RequestHandler -from tornado.httpserver import HTTPServer as TornadoHTTP1Server -from tornado import ioloop - -from ..server.serializers import JSONSerializer -from ..server.database import BaseDB -from ..server.config import global_config -from .models import * -from .handlers import * - - -def create_system_host(db_config_file : typing.Optional[str] = None, ssl_context : typing.Optional[ssl.SSLContext] = None, - handlers : typing.List[typing.Tuple[str, RequestHandler, dict]] = [], **server_settings) -> TornadoHTTP1Server: - """ - global function for creating system hosting server using a database configuration file, SSL context & certain - server settings. Currently supports only one server per process due to usage of some global variables. - """ - disk_DB_URL = BaseDB.create_postgres_URL(db_config_file, database='hololinked-host', use_dialect=False) - if not database_exists(disk_DB_URL): - try: - create_database(disk_DB_URL) - sync_disk_db_engine = create_engine(disk_DB_URL) - HololinkedHostTableBase.metadata.create_all(sync_disk_db_engine) - create_tables(sync_disk_db_engine) - create_credentials(sync_disk_db_engine) - except Exception as ex: - if disk_DB_URL.startswith("sqlite"): - os.remove(disk_DB_URL.split('/')[-1]) - else: - drop_database(disk_DB_URL) - raise ex from None - finally: - sync_disk_db_engine.dispose() - - disk_DB_URL = BaseDB.create_postgres_URL(db_config_file, database='hololinked-host', use_dialect=True) - disk_engine = asyncio_ext.create_async_engine(disk_DB_URL, echo=True) - disk_session = sessionmaker(disk_engine, expire_on_commit=True, - class_=asyncio_ext.AsyncSession) # type: asyncio_ext.AsyncSession - - mem_DB_URL = BaseDB.create_sqlite_URL(in_memory=True) - mem_engine = create_engine(mem_DB_URL, echo=True) - mem_session = sessionmaker(mem_engine, expire_on_commit=True, - class_=Session) # type: Session - HololinkedHostInMemoryTableBase.metadata.create_all(mem_engine) - - CORS = server_settings.pop("CORS", []) - if not isinstance(CORS, (str, list)): - raise TypeError("CORS should be a list of strings or a string") - if isinstance(CORS, str): - CORS = [CORS] - kwargs = dict( - CORS=CORS, - disk_session=disk_session, - mem_session=mem_session - ) - - system_host_compatible_handlers = [] - for handler in handlers: - system_host_compatible_handlers.append((handler[0], handler[1], kwargs)) - - app = Application([ - (r"/", MainHandler, dict(IP="https://localhost:8080", swagger=True, **kwargs)), - (r"/users", UsersHandler, kwargs), - (r"/pages", PagesHandler, kwargs), - (r"/pages/(.*)", PageHandler, kwargs), - (r"/app-settings", AppSettingsHandler, kwargs), - (r"/app-settings/(.*)", AppSettingHandler, kwargs), - (r"/subscribers", SubscribersHandler, kwargs), - # (r"/remote-objects", RemoteObjectsHandler), - (r"/login", LoginHandler, kwargs), - (r"/logout", LogoutHandler, kwargs), - (r"/swagger-ui", SwaggerUIHandler, kwargs), - *system_host_compatible_handlers, - (r"/(.*)", StaticFileHandler, dict(path=os.path.join(os.path.dirname(__file__), - f"assets{os.sep}hololinked-server-swagger-api{os.sep}system-host-api")) - ), - ], - cookie_secret=base64.b64encode(os.urandom(32)).decode('utf-8'), - **server_settings) - - return TornadoHTTP1Server(app, ssl_options=ssl_context) - - -def start_tornado_server(server : TornadoHTTP1Server, port : int = 8080): - server.listen(port) - event_loop = ioloop.IOLoop.current() - print("starting server") - event_loop.start() - - -def create_tables(engine): - with Session(engine) as session, session.begin(): - file = open(f"{os.path.dirname(os.path.abspath(__file__))}{os.sep}assets{os.sep}default_host_settings.json", 'r') - default_settings = JSONSerializer.generic_load(file) - for name, settings in default_settings.items(): - session.add(AppSettings( - field = name, - value = settings - )) - session.commit() - - -def create_credentials(sync_engine): - """ - create name and password for a new user in a database - """ - - print("Requested primary host seems to use a new database. Give username and password (not for database server, but for client logins from hololinked-portal) : ") - email = input("email-id (not collected anywhere else excepted your own database) : ") - while True: - password = getpass.getpass("password : ") - password_confirm = getpass.getpass("repeat-password : ") - if password != password_confirm: - print("password & repeat password not the same. Try again.") - continue - with Session(sync_engine) as session, session.begin(): - ph = PasswordHasher(time_cost=global_config.PWD_HASHER_TIME_COST) - session.add(LoginCredentials(email=email, password=ph.hash(password))) - session.commit() - return - raise RuntimeError("password not created, aborting database creation.") - - -def delete_database(db_config_file): - # config_file = str(Path(os.path.dirname(__file__)).parent) + "\\assets\\db_config.json" - URL = BaseDB.create_URL(db_config_file, database="hololinked-host", use_dialect=False) - drop_database(URL) - - - -__all__ = ['create_system_host'] \ No newline at end of file diff --git a/hololinked/webdashboard/__init__.py b/hololinked/webdashboard/__init__.py deleted file mode 100644 index 2bd519c..0000000 --- a/hololinked/webdashboard/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .baseprops import dataGrid -from .basecomponents import * -from .serializer import * -from .axios import * -from .statemachine import * -from .app import * -from .actions import * diff --git a/hololinked/webdashboard/actions.py b/hololinked/webdashboard/actions.py deleted file mode 100644 index 73ac20d..0000000 --- a/hololinked/webdashboard/actions.py +++ /dev/null @@ -1,139 +0,0 @@ -from typing import List, Union, Any, Dict - -from ..param.parameters import String, ClassSelector, TypedList -from .baseprops import ComponentName, StringProp, StubProp, ActionID -from .utils import unique_id -from .constants import url_regex -from .valuestub import NumberStub, ObjectStub, BooleanStub - - - -class BaseAction: - - actionType = ComponentName(default=__qualname__) - id = ActionID() - - def __init__(self) -> None: - self.id = unique_id(prefix='actionid_') - - def json(self): - return dict(type=self.actionType, id=self.id) - - -class ActionProp(ClassSelector): - - def __init__(self, default=None, **kwargs): - super().__init__(class_=BaseAction, default=default, - per_instance_descriptor=True, deepcopy_default=True, allow_None=True, - **kwargs) - -class ActionListProp(TypedList): - - def __init__(self, default : Union[List, None] = None, **params): - super().__init__(default, item_type=BaseAction, **params) - - -class ComponentOutputProp(ClassSelector): - - def __init__(self): - super().__init__(default=None, allow_None=True, constant=True, class_=ComponentOutput) - - -class setLocation(BaseAction): - - actionType = ComponentName(default="setLocation") - path = StringProp(default='/') - - def json(self): - return dict(**super().json(), path=self.path) - - -class setGlobalLocation(setLocation): - - actionType = ComponentName(default='setGlobalLocation') - - -class EventSource(BaseAction): - - actionType = ComponentName(default='SSE') - url = StringProp(default = None, allow_None=True, regex=url_regex ) - response = StubProp(doc="""response value of the SSE (symbolic JSON specification based pointer - the actual value is in the browser). - Index it to access specific fields within the response.""" ) - readyState = StubProp(doc="0 - connecting, 1 - open, 2 - closed, use it for comparisons") - withCredentials = StubProp(doc="") - onerror = ActionListProp(doc="actions to execute when event source produces error") - onopen = ActionListProp(doc="actions to execute when event source is subscribed and connected") - - def __init__(self, URL : str) -> None: - super().__init__() - self.response = ObjectStub(self.id) - self.readyState = NumberStub(self.id) - self.withCredentials = BooleanStub(self.id) - self.URL = URL - - def json(self): - return dict(**super().json(), URL=self.URL) - - def close(self): - return Cancel(self.id) - - -class Cancel(BaseAction): - - actionType = ComponentName(default='Cancel') - - def __init__(self, id_or_action : str) -> None: - super().__init__() - if isinstance(id_or_action, BaseAction): - self.cancel_id = id_or_action.id - else: - self.cancel_id = id_or_action - - def json(self) -> Dict[str, Any]: - return dict(**super().json(), cancelID=self.cancel_id) - - -class SSEVideoSource(EventSource): - - actionType = ComponentName( 'SSEVideo' ) - - -class SetState(BaseAction): - - actionType = ComponentName(default='setSimpleFSMState') - componentID = String (default=None, allow_None=True) - state = String (default=None, allow_None=True) - - def __init__(self, componentID : str, state : str) -> None: - super().__init__() - self.componentID = componentID - self.state = state - - def json(self) -> Dict[str, Any]: - return dict(**super().json(), - componentID=self.componentID, - state=self.state - ) - -def setState(componentID : str, state : str) -> SetState: - return SetState(componentID, state) - - - -class ComponentOutput(BaseAction): - - actionType = ComponentName(default='componentOutput') - - def __init__(self, outputID : str): - super().__init__() - self.outputID = outputID - - def json(self) -> Dict[str, Any]: - return dict(**super().json(), - outputID=self.outputID, - ) - - -__all__ = ['setGlobalLocation', 'setLocation', 'setGlobalLocation', 'EventSource', - 'Cancel', 'setState', 'SSEVideoSource'] - diff --git a/hololinked/webdashboard/app.py b/hololinked/webdashboard/app.py deleted file mode 100644 index 6571af4..0000000 --- a/hololinked/webdashboard/app.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -import typing -from pprint import pprint -from typing import Dict, Any, Union - -from .baseprops import BooleanProp, ComponentName, TypedListProp, TypedList -from .basecomponents import BaseComponentWithChildren, Page - - - -class ReactApp(BaseComponentWithChildren): - """ - Main React App - kwargs - URL : give the URL to access all the components - """ - componentName = ComponentName(default="__App__") - children : typing.List[Page] = TypedListProp(item_type=Page, - doc="pages in the app, add multiple pages to quickly switch between UIs") - showUtilitySpeedDial : bool = BooleanProp(default=True, allow_None=False) - remoteObjects : list = TypedList(default=None, allow_None=True, item_type=str, - doc="add rmeote object URLs to display connection status") # type: ignore - - def __init__(self): - super().__init__(id='__App__') - - def save_json(self, file_name : str, indent : int = 4, tree : Union[str, None] = None) -> None: - if tree is None: - with open(file_name, 'w') as file: - json.dump(self.json(), file, ensure_ascii=False, allow_nan=True) - else: - with open(file_name, 'w') as file: - json.dump(self.get_component(self.json(), tree), file, ensure_ascii=False, allow_nan=True) - - def print_json(self, tree : Union[str, None] = None) -> None: - if tree is not None: - pprint(self.get_component(self.json(), tree)) - else: - pprint(self.json()) - - def get_component(self, children : Dict[str, Any], tree : str): - for component_json in children.values(): - if component_json["tree"] == tree: - return component_json - raise AttributeError("No component with tree {} found.".format(tree)) - - - -__all__ = ['ReactApp'] \ No newline at end of file diff --git a/hololinked/webdashboard/axios.py b/hololinked/webdashboard/axios.py deleted file mode 100644 index 15f6856..0000000 --- a/hololinked/webdashboard/axios.py +++ /dev/null @@ -1,193 +0,0 @@ -from typing import Union -from collections import namedtuple - -from ..param.parameters import TypedList, ClassSelector, TypedDict -from .baseprops import StringProp, SelectorProp, IntegerProp, BooleanProp, ObjectProp, StubProp, ComponentName -from .valuestub import ObjectStub -from .actions import BaseAction, Cancel -from .utils import unique_id - - - -interceptor = namedtuple('interceptor', 'request response') - -class InterceptorContainer: - - def use(self, callable : BaseAction) -> None: - self.callable = callable - - - -class AxiosRequestConfig(BaseAction): - """ - url : str - method : ['GET', 'POST', 'PUT'] - baseurl : str - headers : dict - params : dict - data : dict - timeout : int > 0 - withCredentials : bool - auth : dict - responseType : ['arraybuffer', 'document', 'json', 'text', 'stream', 'blob'] - xsrfCookieName : str - xsrfHeaderName : str - maxContentLength : int - maxBodyLength : int - maxRedirects : int - """ - actionType = ComponentName ( default = "SingleHTTPRequest") - url = StringProp ( default = None, allow_None = True, - doc = """URL to make request. Enter full URL or just the path without the server. - Server can also be specified in baseURL. Please dont specify server in both URL and baseURL""" ) - method = SelectorProp ( objects = ['get', 'post', 'put', 'delete', 'options'], default = 'post', - doc = "HTTP method of the request" ) - baseurl = StringProp ( default = None, allow_None = True, - doc = "Server or path to prepend to url" ) - # headers = DictProp ( default = {'X-Requested-With': 'XMLAxiosRequestConfigRequest'}, key_type = str, allow_None = True ) - # params = DictProp ( default = None, allow_None = True ) - data = ObjectProp ( default = None, allow_None = True ) - timeout = IntegerProp ( default = 0, bounds = (0,None), allow_None = False ) - # withCredentials = BooleanProp ( default = False, allow_None = False ) - # # auth = DictProp ( default = None, allow_None = True ) - # responseType = SelectorProp ( objects = ['arraybuffer', 'document', 'json', 'text', 'stream', 'blob'], - # default = 'json', allow_None = True ) - # xsrfCookieName = StringProp ( default = 'XSRF-TOKEN' , allow_None = True ) - # xsrfHeaderName = StringProp ( default = 'X-XSRF-TOKEN', allow_None = True ) - # maxContentLength = IntegerProp ( default = 2000, bounds = (0,None), allow_None = False ) - # maxBodyLength = IntegerProp ( default = 2000, bounds = (0,None), allow_None = False ) - # maxRedirects = IntegerProp ( default = 21, bounds = (0,None), allow_None = False ) - # repr = StringProp ( default = "Axios Request Configuration", readonly = True, allow_None = False ) - # intervalAfterTimeout = BooleanProp ( default = False, allow_None = False ) - response = StubProp ( doc = """response value of the request (symbolic JSON specification based pointer - the actual value is in the browser). - Index it to access specific fields within the response.""" ) - onStatus = TypedDict ( default = None, allow_None = True, key_type = int, item_type = BaseAction ) - interceptor = ClassSelector (class_=namedtuple, allow_None=True, constant=True, default=None ) - - - def request_json(self): - return { - 'url' : self.url, - 'method' : self.method, - 'baseurl' : self.baseurl, - 'data' : self.data - } - - def json(self): - return dict(**super().json(), config=self.request_json()) - - def __init__(self, **kwargs) -> None: - super().__init__() - self.response = ObjectStub(self.id) - # self.interceptor = interceptor( - # request = InterceptorContainer(), - # response = InterceptorContainer() - # ) - for key, value in kwargs.items(): - setattr(self, key, value) - - def cancel(self): - return Cancel(self.id) - - -def makeRequest(**params) -> AxiosRequestConfig: - """ - url : str - method : ['GET', 'POST', 'PUT'] - baseurl : str - headers : dict - params : dict - data : dict - timeout : int > 0 - withCredentials : bool - auth : dict - responseType : ['arraybuffer', 'document', 'json', 'text', 'stream', 'blob'] - xsrfCookieName : str - xsrfHeaderName : str - maxContentLength : int - maxBodyLength : int - maxRedirects : int - """ - return AxiosRequestConfig(**params) - - -class QueuedHTTPRequests(BaseAction): - - actionType = ComponentName(default="QueuedHTTPRequests") - requests = TypedList(default=None, allow_None=True, item_type=AxiosRequestConfig, - doc="Request objects that will be fired one after the other, order within the list is respected.") - ignoreFailedRequests = BooleanProp(default=True, - doc="If False, if one request fails, remain requests are dropped. If true, failed requests are ignored.") - - def json(self): - return dict(**super().json(), requests=self.requests, ignoreFailedRequests=True) - - def __init__(self, *args : AxiosRequestConfig, ignore_failed_requests : bool = True) -> None: - super().__init__() - self.requests = list(args) - self.ignoreFailedRequests = ignore_failed_requests - - def cancel(self) -> Cancel: - return Cancel(self.id) - - -class ParallelHTTPRequests(BaseAction): - - actionType = ComponentName(default="ParallelHTTPRequests") - requests = TypedList(default=None, allow_None=True, item_type=AxiosRequestConfig, - doc="Request objects that will be fired one after the other. Order within the list is not important.") - - def json(self): - return dict(**super().json(), requests=self.requests, ignoreFailedRequests=True) - - def __init__(self, *args : AxiosRequestConfig) -> None: - super().__init__() - self.requests = list(args) - - -def makeRequests(*args : AxiosRequestConfig, mode : str = 'serial', ignore_failed_requests : bool = True): - if mode == 'serial': - return QueuedHTTPRequests(*args, ignore_failed_requests=ignore_failed_requests) - elif mode == 'parallel': - return ParallelHTTPRequests(*args) - else: - raise ValueError("Only two modes are supported - serial or parallel. Given value : {}".format(mode)) - - - -class RepeatedRequests(BaseAction): - - actionType = ComponentName(default="RepeatedRequests") - requests = ClassSelector(class_=(AxiosRequestConfig, QueuedHTTPRequests, ParallelHTTPRequests), default = None, - allow_None=True) - interval = IntegerProp(default=None, allow_None=True) - - def __init__(self, requests : Union[AxiosRequestConfig, QueuedHTTPRequests, ParallelHTTPRequests], interval : int = 60000) -> None: - super().__init__() - self.requests = requests - # self.id = requests.id - self.interval = interval - - def json(self): - return dict(super().json(), requests=self.requests, interval=self.interval) - - -def repeatRequest(requests, interval = 60000): - return ( - RepeatedRequests( - requests, - interval = interval - ) - ) - - - -class RequestProp(ClassSelector): - - def __init__(self, default = None, **params): - super().__init__(class_=(AxiosRequestConfig, QueuedHTTPRequests, ParallelHTTPRequests, RepeatedRequests), default=default, - deepcopy_default=False, allow_None=True, **params) - - -__all__ = ['makeRequest', 'AxiosRequestConfig', 'QueuedHTTPRequests', 'ParallelHTTPRequests', 'RepeatedRequests', - 'makeRequests', 'repeatRequest' ] \ No newline at end of file diff --git a/hololinked/webdashboard/basecomponents.py b/hololinked/webdashboard/basecomponents.py deleted file mode 100644 index 3aa5ebf..0000000 --- a/hololinked/webdashboard/basecomponents.py +++ /dev/null @@ -1,238 +0,0 @@ -import typing - -from ..param.parameters import TypedList as TypedListProp, String, ClassSelector -from ..param.exceptions import raise_ValueError -from ..param import edit_constant, Parameterized, Parameter - -from .baseprops import (TypedListProp, NodeProp, StringProp, HTMLid, ComponentName, SelectorProp, - StubProp, dataGrid, ObjectProp, IntegerProp, BooleanProp, NumberProp, - TupleSelectorProp) -from .actions import ComponentOutput, ComponentOutputProp -from .exceptions import * -from .statemachine import StateMachineProp -from .valuestub import ValueStub -from .utils import unique_id - - - - -UICOMPONENTS = 'UIcomponents' -ACTIONS = 'actions' -NODES = 'nodes' - -class ReactBaseComponent(Parameterized): - """ - Validator class that serves as the information container for all components in the React frontend. The - parameters of this class are serialized to JSON, deserialized at frontend and applied onto components as props, - i.e. this class is a JSON specfication generator. - - Attributes: - - id : The html id of the component, allowed characters are < - tree : read-only, used for generating unique key prop for components, it denotes the nesting of the component based on id. - repr : read-only, used for raising meaningful Exceptions - stateMachine : specify state machine for front end components - """ - componentName = ComponentName(default='ReactBaseComponent') - id = HTMLid(default=None) - tree = String(default=None, allow_None=True, constant=True) - stateMachine = StateMachineProp(default=None, allow_None=True) - # outputID = ActionID() - - def __init__(self, id : str, **params): - # id separated in kwargs to force the user to give it - self.id = id # & also be set as the first parameter before all other parameters - super().__init__(**params) - parameters = self.parameters.descriptors - with edit_constant(parameters['tree']): - self.tree = id - try: - """ - Multiple action_result stubs are possible however, only one internal action stub is possible. - For example, a button's action may be to store the number of clicks, make request etc., however - only number of clicks can be an internal action although any number of other stubs may access this value. - """ - self.action_id = unique_id('actionid_') - # if self.outputID is None: - # with edit_constant(parameters['outputID']): - # self.outputID = action_id - for param in parameters.values(): - if isinstance(param, StubProp): - stub = param.__get__(self, type(self)) - stub.create_base_info(self.action_id) - for param in parameters.values(): - if isinstance(param, ComponentOutputProp): - output_action = ComponentOutput(outputID=self.action_id) - param.__set__(self, output_action) - stub.create_base_info(output_action.id) - except Exception as ex: - raise RuntimeError(f"stub information improperly configured for component {self.componentName} : received exception : {str(ex)}") - - def validate(self): - """ - use this function in a child component to perform type checking and assignment which is appropriate at JSON creation time i.e. - the user has sufficient space to manipulate parts of the props of the component until JSON is created. - Dont forget to call super().secondStagePropCheck as its mandatory to ensure state machine of the - """ - if self.id is None: - raise_ValueError("ID cannot be None for {}".format(self.componentName), self) - - def __str__(self): - if self.id is not None: - return "{} with HTML id : {}".format(self.componentName, self.id) - else: - return self.componentName - - def json(self, JSON : typing.Dict[str, typing.Any] = {}) -> typing.Dict[str, typing.Any]: - self.validate() - JSON[UICOMPONENTS].update({self.id : self.parameters.serialize(mode='FrontendJSONSpec')}) - JSON[ACTIONS].update(JSON[UICOMPONENTS][self.id].pop(ACTIONS, {})) - return JSON - - - -class BaseComponentWithChildren(ReactBaseComponent): - - children = TypedListProp(doc="children of the component", - item_type=(ReactBaseComponent, ValueStub, str)) - - def addComponent(self, *args : ReactBaseComponent) -> None: - for component in args: - if component.componentName == 'ReactApp': - raise TypeError("ReactApp can never be child of any component") - if self.children is None: - self.children = [component] - else: - self.children.append(component) - - def json(self, JSON: typing.Dict[str, typing.Any] = {UICOMPONENTS : {}, ACTIONS : {}}) -> typing.Dict[str, typing.Any]: - super().json(JSON) - if self.children != None and len(self.children) > 0: - for child in self.children: - if isinstance(child, ReactBaseComponent): - with edit_constant(child.parameters.descriptors['tree']): - if child.id is None: - raise_PropError(AttributeError("object {} id is None, cannot create JSON".format(child)), - self, 'children') - child.tree = self.tree + '/' + child.id - child.json(JSON) - return JSON - - -class ReactGridLayout(BaseComponentWithChildren): - - componentName = ComponentName(default="ContextfulRGL") - width = IntegerProp(default=300, - doc='This allows setting the initial width on the server side. \ - This is required unless using the HOC or similar') - autoSize = BooleanProp(default=False, - doc='If true, the container height swells and contracts to fit contents') - cols = IntegerProp(default=300, doc='Number of columns in this layout') - draggableCancel = StringProp(default='', - doc='A CSS selector for tags that will not be draggable. \ - For example: draggableCancel: .MyNonDraggableAreaClassName \ - If you forget the leading . it will not work. \ - .react-resizable-handle" is always prepended to this value.') - draggableHandle = StringProp(default='', - doc='CSS selector for tags that will act as the draggable handle.\ - For example: draggableHandle: .MyDragHandleClassName \ - If you forget the leading . it will not work.') - compactType = SelectorProp(default=None, objects=[None, 'vertical', 'horizontal'], - doc='compaction type') - layout = ObjectProp(default=None, - doc='Layout is an array of object with the format: \ - {x: number, y: number, w: number, h: number} \ - The index into the layout must match the key used on each item component. \ - If you choose to use custom keys, you can specify that key in the layout \ - array objects like so: \ - {i: string, x: number, y: number, w: number, h: number} \ - If not provided, use data-grid props on children') - margin = Parameter(default=[10, 10], doc='') - isDraggable = BooleanProp(default=False) - isResizable = BooleanProp(default=False) - isBounded = BooleanProp(default=False) - preventCollision = BooleanProp(default=False, - doc="If true, grid items won't change position when being \ - dragged over. If `allowOverlap` is still false, \ - this simply won't allow one to drop on an existing object.") - containerPadding = Parameter(default=[10, 10], doc='') - rowHeight = IntegerProp(default=300, bounds=(0, None), - doc='Rows have a static height, but you can change this based on breakpoints if you like.') - useCSSTransforms = BooleanProp(default=True, - doc='Uses CSS3 translate() instead of position top/left. \ - This makes about 6x faster paint performance') - transformScale = NumberProp(default=1, doc='') - resizeHandles = TupleSelectorProp(default=['se'], objects=['s', 'w', 'e', 'n' , 'sw', 'nw', 'se', 'ne'], - doc='Defines which resize handles should be rendered', accept_list=True) - resizeHandle = NodeProp(default=None, class_=ReactBaseComponent, allow_None=True, - doc='Custom component for resize handles') - - def validate(self): - for child in self.children: - if not hasattr(child, "RGLDataGrid"): - raise_PropError(AttributeError( - "component {} with id '{}' cannot be child of ReactGridLayout. It does not have a built-in dataGrid support.".format( - child.__class__.__name__, child.id)), self, "children") - elif child.RGLDataGrid is None: # type: ignore - raise_PropError(ValueError( - "component {} with id '{}', being child of ReactGridLayout, dataGrid prop cannot be unassigned or None".format( - child.__class__.__name__, child.id)), self, "children") - super().validate() - - -class Page(BaseComponentWithChildren): - """ - Adds a new page in app. All components by default can be only within a page. Its not possible to - directly add a component to an app without the page. If only one page exists, its shown automatically. - For a list of pages, its possible to set URL paths and show available pages. - """ - componentName = ComponentName(default="ContextfulPage") - route = StringProp(default='/', regex=r'^\/[a-zA-Z][a-zA-Z0-9]*$|^\/$', - doc="route for a page, mandatory if there are many pages, must be unique") - name = StringProp(default=None, allow_None=True, doc="display name for a page") - - def __init__(self, id : str, route : str = '/', name : typing.Optional[str] = None, - **params): - super().__init__(id=id, route=route, name=name, **params) - - -class RGLBaseComponent(BaseComponentWithChildren): - """ - All components which can be child of a react-grid-layout grid must be a child of this class, - """ - componentName = ComponentName (default='ReactGridLayoutBaseComponent' ) - RGLDataGrid = ClassSelector(default=None, allow_None=True, class_=dataGrid, - doc="use this prop to specify location in a ReactGridLayout component") - - def __init__(self, id : str, dataGrid : typing.Optional[dataGrid] = None, **params): - super().__init__(id=id, RGLDataGrid=dataGrid, **params) - - -class MUIBaseComponent(RGLBaseComponent): - """ - All material UI components must be a child of this class. sx, styling, classes & 'component' prop - are already by default available. This component is also react-grid-layout compatible. - """ - componentName = ComponentName(default="MUIBaseComponent") - sx = ObjectProp(doc="""The system prop that allows defining system overrides as well as additional CSS styles. - See the `sx` page for more details.""") - component = NodeProp(class_=(ReactBaseComponent, str), default=None, allow_None=True, - doc="The component used for the root node. Either a string to use a HTML element or a component.") - styling = ObjectProp() - classes = ObjectProp() - - def __init__(self, id : str, dataGrid : typing.Optional[dataGrid] = None, - sx : typing.Optional[typing.Dict[str, typing.Any]] = None, - component : typing.Optional[typing.Union[str, ReactBaseComponent]] = None, - styling : typing.Optional[typing.Dict[str, typing.Any]] = None, - classes : typing.Optional[typing.Dict[str, typing.Any]] = None, - **params): - super().__init__(id=id, dataGrid=dataGrid, sx=sx, component=component, - styling=styling, classes=classes, **params) - - - - -__all__ = ['ReactBaseComponent', 'ReactGridLayout', 'RGLBaseComponent', 'Page', 'MUIBaseComponent', - 'BaseComponentWithChildren'] - \ No newline at end of file diff --git a/hololinked/webdashboard/baseprops.py b/hololinked/webdashboard/baseprops.py deleted file mode 100644 index 14b620b..0000000 --- a/hololinked/webdashboard/baseprops.py +++ /dev/null @@ -1,187 +0,0 @@ -import typing - -from ..param.parameters import (String, Integer, Number, Boolean, Selector, - ClassSelector, TupleSelector, Parameter, TypedList, TypedDict) -from ..param.exceptions import raise_ValueError -from .valuestub import ValueStub, StringStub, NumberStub, BooleanStub -from .exceptions import * - - -class Prop(Parameter): - pass - - -class StringProp(String): - - def validate_and_adapt(self : Parameter, value : typing.Any) -> typing.Union[typing.Any, - ValueStub]: - if isinstance(value, StringStub): - return value - return super().validate_and_adapt(value) - - -class NumberProp(Number): - - def validate_and_adapt(self : Parameter, value : typing.Any) -> typing.Union[typing.Any, - ValueStub]: - if isinstance(value, NumberStub): - return value - return super().validate_and_adapt(value) - - -class IntegerProp(Integer): - - def validate_and_adapt(self : Parameter, value : typing.Any) -> typing.Union[typing.Any, - ValueStub]: - if isinstance(value, NumberStub): - return value - return super().validate_and_adapt(value) - - -class BooleanProp(Boolean): - - def validate_and_adapt(self : Parameter, value : typing.Any) -> typing.Union[typing.Any, - ValueStub]: - if isinstance(value, BooleanStub): - return value - return super().validate_and_adapt(value) - - -class SelectorProp(Selector): - - def validate_and_adapt(self : Parameter, value : typing.Any) -> typing.Union[typing.Any, - ValueStub]: - if isinstance(value, ValueStub): - return value - return super().validate_and_adapt(value) - - -class TupleSelectorProp(TupleSelector): - - def validate_and_adapt(self : Parameter, value : typing.Any) -> typing.Union[typing.Any, - ValueStub]: - if isinstance(value, ValueStub): - return value - return super().validate_and_adapt(value) - - -class ObjectProp(TypedDict): - - def __init__(self, default : typing.Union[typing.Dict[typing.Any, typing.Any], None] = None, - item_type : typing.Any = None, bounds : tuple = (0, None), allow_None : bool = True, - **params): - super().__init__(default, key_type=str, item_type=item_type, bounds=bounds, - allow_None=allow_None, **params) - - -class NodeProp(ClassSelector): - """ - For props which is supposed to accept only one child instead of multiple children - """ - def validate_and_adapt(self : Parameter, value : typing.Any) -> typing.Union[typing.Any, - ValueStub]: - if isinstance(value, ValueStub): - return value - return super().validate_and_adapt(value) - - -class TypedListProp(TypedList): - - def __init__(self, default: typing.Union[typing.List[typing.Any], None] = None, - item_type: typing.Any = None, bounds: tuple = (0, None), **params): - super().__init__(default, item_type=item_type, bounds=bounds, allow_None=True, - accept_nonlist_object=True, **params) - - -class ComponentName(String): - - def __init__(self, default = ""): - super().__init__(default, regex=r"[A-Za-z_]+[A-Za-z]*", allow_None=False, readonly=True, - doc="Constant internal value for mapping ReactBaseComponent subclasses to renderable components in front-end") - - -class HTMLid(String): - - def __init__(self, default: typing.Union[str, None] = "") -> None: - super().__init__(default, regex = r'[A-Za-z_]+[A-Za-z_0-9\-]*', allow_None = True, - doc = "HTML id of the component, must be unique.") - - def validate_and_adapat(self, value : typing.Any) -> str: - if value == "App": - raise_ValueError("HTML id 'App' is reserved for `ReactApp` instances. Please use another id value.", self) - return super().validate_and_adapt(value) - - - -class ActionID(String): - - def __init__(self, **kwargs) -> None: - super().__init__(default = None, regex = r'[A-Za-z_]+[A-Za-z_0-9\-]*', allow_None = True, constant = True, - doc = "Action id of the component, must be unique. No need to set as its internally generated.", **kwargs) - - -class dataGrid: - __allowedKeys__ = ['x', 'y', 'w', 'h', 'minW', 'maxW', 'minH', 'maxH', 'static', - 'isDraggable', 'isResizable', 'isBounded', 'resizeHandles'] - - x = NumberProp(default=0, bounds=(0, None), allow_None=False) - y = NumberProp(default=0, bounds=(0, None), allow_None=False) - w = NumberProp(default=0, bounds=(0, None), allow_None=False) - h = NumberProp(default=0, bounds=(0, None), allow_None=False) - minW = NumberProp(default=None, bounds=(0, None), allow_None=True) - maxW = NumberProp(default=None, bounds=(0, None), allow_None=True) - minH = NumberProp(default=None, bounds=(0, None), allow_None=True) - maxH = NumberProp(default=None, bounds=(0, None), allow_None=True) - static = BooleanProp(default=True, allow_None=True) - isDraggable = BooleanProp(default=None, allow_None=True) - isResizable = BooleanProp(default=None, allow_None=True) - isBounded = BooleanProp(default=None, allow_None=True) - resizeHandles = TupleSelector(default=['se'], objects=['s' , 'w' , 'e' , 'n' , 'sw' , 'nw' , 'se' , 'ne']) - - def __init__(self, **kwargs) -> None: - for key in kwargs.keys(): - if key not in self.__allowedKeys__: - raise AttributeError("unknown key `{}` for dataGrid prop.".format(key)) - for key, value in kwargs.items(): - setattr(self, key, value) - - def json(self): - d = dict( - x = self.x, - y = self.y, - w = self.w, - h = self.h, - minW = self.minW, - maxW = self.maxW, - minH = self.minH, - maxH = self.maxH, - static = self.static, - isDraggable = self.isDraggable, - isResizable = self.isResizable, - isBounded = self.isBounded, - resizeHandles = self.resizeHandles - ) - none_keys = [] - for key in self.__allowedKeys__: - if d[key] is None: - none_keys.append(key) - for key in none_keys: - d.pop(key, None) - return d - - -class StubProp(ClassSelector): - - def __init__(self, default=None, **params): - super().__init__(class_=ValueStub, default=default, constant=True, **params) - - -class UnsupportedProp(Parameter): - - def __init__(self, doc : typing.Union[str, None] = "This prop is not supported as it generally executes a client-side only function" ): - super().__init__(None, doc=doc, constant=True, readonly=True, allow_None=True) - - -class ClassSelectorProp(ClassSelector): - pass - diff --git a/hololinked/webdashboard/components/__init__.py b/hololinked/webdashboard/components/__init__.py deleted file mode 100644 index 2e3724a..0000000 --- a/hololinked/webdashboard/components/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .plotly import PlotlyFigure -from .ace_editor import AceEditor -from .mui import * \ No newline at end of file diff --git a/hololinked/webdashboard/components/ace_editor.py b/hololinked/webdashboard/components/ace_editor.py deleted file mode 100644 index 9af6b0b..0000000 --- a/hololinked/webdashboard/components/ace_editor.py +++ /dev/null @@ -1,82 +0,0 @@ -from ..basecomponents import RGLBaseComponent -from ..baseprops import (StringProp, SelectorProp, IntegerProp, BooleanProp, - StubProp, NumberProp, ObjectProp, TypedList, ComponentName, - UnsupportedProp) -from ..axios import RequestProp -from ..actions import ComponentOutputProp -from ..valuestub import StringStub - - -class AceEditor(RGLBaseComponent): - """ - For docs on each prop refer - https://github.com/securingsincity/react-ace/blob/master/docs/Ace.md - """ - placeholder = StringProp(default="", doc="Placeholder text to be displayed when editor is empty") - mode = SelectorProp(objects=["javascript", "java", "python", "xml", "ruby", "sass", - "markdown", "mysql", "json", "html", "handlebars", "golang", - "csharp", "coffee", "css"], - default='json', doc="Language for parsing and code highlighting") - theme = SelectorProp(objects=["monokai", "github", "tomorrow", "kuroir", "twilight", "crimson_editor", - "xcode", "textmate", "solarized dark", "solarized light", "terminal"], - default="crimson_editor", doc="Language for parsing and code highlighting") - value = StubProp(default=None) - defaultValue = StringProp(default="", doc="Default value of the editor") - height = StringProp(default="", doc="CSS value for height") - width = StringProp(default="", doc="CSS value for width") - # className = StringProp(default="", doc="custom className") - fontSize = IntegerProp(default=12, doc="pixel value for font-size", bounds=(1, None)) - showGutter = BooleanProp(default=True, doc="show gutter") - showPrintMargin = BooleanProp(default=True, doc="show print margin") - highlightActiveLine = BooleanProp(default=True, doc="highlight active line") - focus = BooleanProp(default=False, doc="whether to focus") - cursorStart = IntegerProp(default=1, doc="the location of the cursor", bounds=(1, None)) - wrapEnabled = BooleanProp(default=False, doc="Wrapping lines") - readOnly = BooleanProp(default=False, doc="make the editor read only") - minLines = IntegerProp(default=12, doc="Minimum number of lines to be displayed", bounds=(1, None)) - maxLines = IntegerProp(default=12, doc="Maximum number of lines to be displayed", bounds=(1, None)) - enableBasicAutocompletion = BooleanProp(default=False, doc="Enable basic autocompletion") - enableLiveAutocompletion = BooleanProp(default=False, doc="Enable live autocompletion") - enableSnippets = BooleanProp(default=False, doc="Enable snippets") - tabSize = IntegerProp(default=4, doc="tabSize", bounds=(1, None)) - debounceChangePeriod= NumberProp(default=None, allow_None=True, doc="A debounce delay period for the onChange event", - bounds=(0, None)) - onLoad = RequestProp(doc="called on editor load. The first argument is the instance of the editor") - onBeforeLoad = RequestProp(doc="called before editor load. the first argument is an instance of ace") - onChange = ComponentOutputProp() - # These props are generally client side - onCopy = UnsupportedProp() - onPaste = UnsupportedProp() - onSelectionChange = UnsupportedProp() - onCursorChange = UnsupportedProp() - onFocus = UnsupportedProp() - onBlur = UnsupportedProp() - onInput = UnsupportedProp() - onScroll = UnsupportedProp() - onValidate = UnsupportedProp() - editorProps = ObjectProp(doc="properties to apply directly to the Ace editor instance") - setOptions = ObjectProp(doc="options to apply directly to the Ace editor instance") - keyboardHandler = StringProp(doc="corresponding to the keybinding mode to set (such as vim or emacs)", default="") - commands = TypedList(doc=""" - new commands to add to the editor - """) - annotations = TypedList(doc=""" - annotations to show in the editor i.e. [{ row: 0, column: 2, type: 'error', text: 'Some error.'}], displayed in the gutter - """) - markers = TypedList(doc=""" - markers to show in the editor, - i.e. [{ startRow: 0, startCol: 2, endRow: 1, endCol: 20, className: 'error-marker', type: 'background' }]. - Make sure to define the class (eg. ".error-marker") and set position: absolute for it. - """) - style = ObjectProp(doc="camelCased properties") - componentName = ComponentName(default="ContextfulAceEditor") - - def __init__(self, **params): - self.editorProps = {} - self.setOptions = {} - self.style = {} - self.value = StringStub() - super().__init__(**params) - - - -__all__ = ["AceEditor"] \ No newline at end of file diff --git a/hololinked/webdashboard/components/mui/Button.py b/hololinked/webdashboard/components/mui/Button.py deleted file mode 100644 index dfd1aa9..0000000 --- a/hololinked/webdashboard/components/mui/Button.py +++ /dev/null @@ -1,142 +0,0 @@ -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import ComponentName, SelectorProp, BooleanProp, NodeProp, StringProp, ObjectProp, UnsupportedProp -from ...actions import ActionProp - - - -class ButtonBase(MUIBaseComponent): - """ - centerRipple : bool - disabled : bool - disableRipple : bool - disableTouchRipple : bool - focusRipple : bool - focusVisibleClassName : str - LinkComponent : component - onFocusVisible : AxiosRequestConfig | makeRequest() - TouchRippleProps : dict - - centerRipple : "If true, the ripples are centered. They won't start at the cursor interaction position." - disabled : "If true, the component is disabled." - disableRipple : "If true, the ripple effect is disabled.⚠️ Without a ripple there is no styling for :focus-visible by default. Be sure to highlight the element by applying separate styles with the .Mui-focusVisible class." - disableTouchRipple : "If true, the touch ripple effect is disabled." - focusRipple : "If true, the base button will have a keyboard focus ripple." - focusVisibleClassName : "This prop can help identify which element has keyboard focus. The class name will be applied when the element gains the focus through keyboard interaction. It's a polyfill for the CSS :focus-visible selector. The rationale for using this feature is explained here. A polyfill can be used to apply a focus-visible class to other components if needed." - LinkComponent : "The component used to render a link when the href prop is provided." - onFocusVisible : "Callback fired when the component is focused with a keyboard. We trigger a onFocus callback too." - TouchRippleProps : "Props applied to the TouchRipple element." - touchRippleRef : "A ref that points to the TouchRipple element." - """ - - componentName = ComponentName(default="ContextfulMUIButtonBase") - centerRipple = BooleanProp(default=False, - doc="If true, the ripples are centered. They won't start at the cursor interaction position.") - disabled = BooleanProp(default=False, - doc="If true, the component is disabled.") - disableRipple = BooleanProp (default=False, - doc="If true, the ripple effect is disabled.⚠️ Without a ripple there is no styling for :focus-visible by default. \ - Be sure to highlight the element by applying separate styles with the .Mui-focusVisible class." ) - disableTouchRipple = BooleanProp(default = False, - doc="If true, the touch ripple effect is disabled.") - focusRipple = BooleanProp(default = False, - doc="If true, the base button will have a keyboard focus ripple.") - focusVisibleClassName = StringProp(default=None, allow_None=True, - doc="This prop can help identify which element has keyboard focus. \ - The class name will be applied when the element gains the focus through keyboard interaction. \ - It's a polyfill for the CSS :focus-visible selector. The rationale for using this feature is explained here. \ - A polyfill can be used to apply a focus-visible class to other components if needed." ) - LinkComponent = NodeProp(class_ = (ReactBaseComponent, str), default = 'a', - doc="The component used to render a link when the href prop is provided.") - TouchRippleProps = ObjectProp(doc="Props applied to the TouchRipple element.") - touchRippleRef = UnsupportedProp() - onFocusVisible = UnsupportedProp() - action = UnsupportedProp() - - - - -class Button(ButtonBase): - """ - color : 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' - disableElevation : bool - disableFocusRipple : bool - endIcon : component - fullWidth : bool - size : 'small' | 'medium' | 'large' - startIcon : component - variant : 'contained' | 'outlined' | 'text' - - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - disableElevation : "If true, no elevation is used." - disableFocusRipple : "If true, the keyboard focus ripple is disabled." - endIcon : "Element placed after the children." - fullWidth : "If true, the button will take up the full width of its container." - size : "The size of the component. small is equivalent to the dense button styling." - startIcon : "Element placed before the children." - variant : "The variant to use." - """ - componentName = ComponentName ( default = "ContextfulMUIButton" ) - color = SelectorProp ( objects = [ 'inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'], default = 'primary', - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." ) - disableElevation = BooleanProp ( default = False, - doc = "If true, no elevation is used." ) - disableRipple = BooleanProp ( default = False, - doc = "If true, the ripple effect is disabled.⚠️ Without a ripple there is no styling for :focus-visible by default. Be sure to highlight the element by applying separate styles with the .Mui-focusVisible class." ) - endIcon = NodeProp ( class_ = ReactBaseComponent, default = None, allow_None = True, - doc = "Element placed after the children." ) - fullWidth = BooleanProp ( default = False, - doc = "If true, the button will take up the full width of its container." ) - size = SelectorProp ( objects = [ 'small', 'medium', 'large'], default = 'medium', - doc = "The size of the component. small is equivalent to the dense button styling." ) - startIcon = NodeProp ( class_ = ReactBaseComponent, default = None, allow_None = True, - doc = "Element placed before the children." ) - variant = SelectorProp ( objects = [ 'contained', 'outlined', 'text'], default = 'text', - doc = "The variant to use." ) - onClick = ActionProp ( default = None, - doc = "server resource to reach when button is clicked." ) - - -class hrefButton(Button): - """ - href : str - centerRipple : bool - disabled : bool - disableRipple : bool - disableTouchRipple : bool - focusRipple : bool - focusVisibleClassName : str - LinkComponent : component - onFocusVisible : AxiosRequestConfig | makeRequest() - TouchRippleProps : dict - color : 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' - disableElevation : bool - disableFocusRipple : bool - endIcon : component - fullWidth : bool - size : 'small' | 'medium' | 'large' - startIcon : component - variant : 'contained' | 'outlined' | 'text' - - href : "The URL to link to when the button is clicked. If defined, an a element will be used as the root node." - centerRipple : "If true, the ripples are centered. They won't start at the cursor interaction position." - disabled : "If true, the component is disabled." - disableRipple : "If true, the ripple effect is disabled.⚠️ Without a ripple there is no styling for :focus-visible by default. Be sure to highlight the element by applying separate styles with the .Mui-focusVisible class." - disableTouchRipple : "If true, the touch ripple effect is disabled." - focusRipple : "If true, the base button will have a keyboard focus ripple." - focusVisibleClassName : "This prop can help identify which element has keyboard focus. The class name will be applied when the element gains the focus through keyboard interaction. It's a polyfill for the CSS :focus-visible selector. The rationale for using this feature is explained here. A polyfill can be used to apply a focus-visible class to other components if needed." - LinkComponent : "The component used to render a link when the href prop is provided." - onFocusVisible : "Callback fired when the component is focused with a keyboard. We trigger a onFocus callback too." - TouchRippleProps : "Props applied to the TouchRipple element." - touchRippleRef : "A ref that points to the TouchRipple element." - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - disableElevation : "If true, no elevation is used." - disableFocusRipple : "If true, the keyboard focus ripple is disabled." - endIcon : "Element placed after the children." - fullWidth : "If true, the button will take up the full width of its container." - size : "The size of the component. small is equivalent to the dense button styling." - startIcon : "Element placed before the children." - variant : "The variant to use." - """ - componentName = ComponentName ( "ContextfulMUIhRefButton" ) - href = StringProp ( default = None, allow_None = True, - doc = "The URL to link to when the button is clicked. If defined, an a element will be used as the root node." ) diff --git a/hololinked/webdashboard/components/mui/ButtonGroup.py b/hololinked/webdashboard/components/mui/ButtonGroup.py deleted file mode 100644 index 8ad3929..0000000 --- a/hololinked/webdashboard/components/mui/ButtonGroup.py +++ /dev/null @@ -1,50 +0,0 @@ -from ...basecomponents import MUIBaseComponent -from ...baseprops import ComponentName, SelectorProp, BooleanProp - - - -class ButtonGroup(MUIBaseComponent): - """ - color : 'inherit' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - disabled : bool - disableElevation : bool - disableFocusRipple : bool - disableRipple : bool - fullWidth : bool - orientation : 'horizontal' | 'vertical' - size : 'small' | 'medium' | 'large' - variant : 'contained' | 'outlined' | 'text' - componentName : read-only - - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - disabled : "If true, the component is disabled." - disableElevation : "If true, no elevation is used." - disableFocusRipple : "If true, the button keyboard focus ripple is disabled." - disableRipple : "If true, the button ripple effect is disabled." - fullWidth : "If true, the buttons will take up the full width of its container." - orientation : "The component orientation (layout flow direction)." - size : "The size of the component. small is equivalent to the dense button styling." - variant : "The variant to use." - componentName : "Constant internal value for mapping classes to components in front-end" - """ - color = SelectorProp ( objects = [ 'inherit', 'primary', 'secondary', 'error', 'info', 'success', 'warning'], default = 'primary', allow_None = False, - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." ) - disabled = BooleanProp ( default = False, allow_None = False, - doc = "If true, the component is disabled." ) - disableElevation = BooleanProp ( default = False, allow_None = False, - doc = "If true, no elevation is used." ) - disableFocusRipple = BooleanProp ( default = False, allow_None = False, - doc = "If true, the button keyboard focus ripple is disabled." ) - disableRipple = BooleanProp ( default = False, allow_None = False, - doc = "If true, the button ripple effect is disabled." ) - fullWidth = BooleanProp ( default = False, allow_None = False, - doc = "If true, the buttons will take up the full width of its container." ) - orientation = SelectorProp ( objects = [ 'horizontal', 'vertical'], default = 'horizontal', allow_None = False, - doc = "The component orientation (layout flow direction)." ) - size = SelectorProp ( objects = [ 'small', 'medium', 'large'], default = 'medium', allow_None = False, - doc = "The size of the component. small is equivalent to the dense button styling." ) - variant = SelectorProp ( objects = [ 'contained', 'outlined', 'text'], default = 'outlined', allow_None = False, - doc = "The variant to use." ) - componentName = ComponentName(default = "ContextfulMUIButtonGroup") - - diff --git a/hololinked/webdashboard/components/mui/Checkbox.py b/hololinked/webdashboard/components/mui/Checkbox.py deleted file mode 100644 index a7f1de8..0000000 --- a/hololinked/webdashboard/components/mui/Checkbox.py +++ /dev/null @@ -1,82 +0,0 @@ -import typing -from ...basecomponents import ReactBaseComponent -from ...baseprops import (ComponentName, BooleanProp, StringProp, - NodeProp, SelectorProp, ObjectProp, UnsupportedProp, StubProp) -from ...valuestub import StringStub -from ...actions import ActionListProp, ComponentOutput, BaseAction -from .Button import ButtonBase - - -class Checkbox(ButtonBase): - """ - Visit https://mui.com/material-ui/api/checkbox/ for React MUI docs - - checked : bool - checkedIcon : component - color : None - defaultChecked : bool - disabled : bool - disableRipple : bool - icon : component - id : str - indeterminate : bool - indeterminateIcon : component - inputProps : dict - inputRef : unsupported - onChange : BaseAction - required : bool - size : None - value : any - """ - checked = BooleanProp(default=None, allow_None=True, - doc="If true, the component is checked.") - checkedIcon = NodeProp(class_=(ReactBaseComponent, str), default="CheckBoxIcon", - allow_None=False, doc="The icon to display when the component is checked.") - color = SelectorProp(objects=['default', 'primary', 'secondary', 'error', - 'info', 'success', 'warning'], default='primary', allow_None=False, - doc="The color of the component. It supports both default and \ - custom theme colors, which can be added as shown in the palette customization guide.") - defaultChecked = BooleanProp(default=None, allow_None=True, - doc="The default checked state. Use when the component is not controlled.") - disabled = BooleanProp(default=False, allow_None=False, - doc="If true, the component is disabled.") - disableRipple = BooleanProp(default=False, allow_None=False, - doc="If true, the ripple effect is disabled.") - icon = NodeProp(class_=(ReactBaseComponent, str), default="CheckBoxOutlineBlankIcon", - allow_None=False, doc="The icon to display when the component is unchecked.") - input_element_id = StringProp(default=None, allow_None=True, - doc="The id of the input element.") - indeterminate = BooleanProp(default=False, allow_None=False, - doc="If true, the component appears indeterminate. This does not set \ - the native input element to indeterminate due to inconsistent behavior across browsers. \ - However, we set a data-indeterminate attribute on the input.") - indeterminateIcon = NodeProp(class_=(ReactBaseComponent, str), - default="IndeterminateCheckBoxIcon", allow_None=False, - doc="The icon to display when the component is indeterminate.") - inputProps = ObjectProp(default=None, allow_None=True, - doc="Attributes applied to the input element.") - inputRef = UnsupportedProp() - onChange : typing.List[BaseAction] = ActionListProp(default=None, - doc="Callback fired when the state is changed.") - required = BooleanProp(default=False, allow_None=False, - doc="If true, the input element is required.") - size = SelectorProp(objects=['medium', 'small'], default='medium', allow_None=False, - doc="The size of the component. small is equivalent to the dense checkbox styling.") - value = StubProp(default=None, - doc="The value of the component. The DOM API casts this to a string. \ - The browser uses on as the default value.") - componentName = ComponentName(default='ContextfulMUICheckbox') - - def __init__(self, id : str, checked : bool = False, checkedIcon : str = 'CheckboxIcon', color : str = 'primary', - defaultChecked : bool = False, disabled : bool = False, disableRipple : bool = False, - icon : str = 'CheckedOutlineBlankIcon', input_element_id : typing.Optional[str] = None, - indeterminate : bool = False, indeterminateIcon : str = 'IndeterminateCheckBoxIcon', - inputProps : typing.Optional[dict] = None, onChange : typing.Optional[BaseAction] = None, - required : bool = False, size : str = 'medium'): - self.value = StringStub() - self.onChange = onChange - self.onChange.append(ComponentOutput()) - super().__init__(id=id, checked=checked, checkedIcon=checkedIcon, color=color, - defaultChecked=defaultChecked, disabled=disabled, disableRipple=disableRipple, icon=icon, - input_element_id=input_element_id, indeterminate=indeterminate, indeterminateIcon=indeterminateIcon, - inputProps=inputProps, onChange=self.onChange, required=required, size=size) \ No newline at end of file diff --git a/hololinked/webdashboard/components/mui/Divider.py b/hololinked/webdashboard/components/mui/Divider.py deleted file mode 100644 index 6d69a06..0000000 --- a/hololinked/webdashboard/components/mui/Divider.py +++ /dev/null @@ -1,17 +0,0 @@ -from ...basecomponents import MUIBaseComponent -from ...baseprops import BooleanProp, SelectorProp, ComponentName - - -class Divider(MUIBaseComponent): - absolute = BooleanProp(default=False, doc="Absolutely position the element" ) - flexItem = BooleanProp(default=False, - doc="""If true, a vertical divider will have the correct height when used in flex container. - By default, a vertical divider will have a calculated height of 0px if it is the child of a flex container.""") - light = BooleanProp(default=False, doc="If true, the divider will have a lighter color") - orientation = SelectorProp(default='horizontal', objects=['horizontal', 'vertical'], - doc="The component orientation.") - textAlign = SelectorProp(default='center', objects=['center', 'left', 'right'], - doc="The text alignment.") - variant = SelectorProp(default='fullWidth', objects=['fullWidth', 'inset', 'middle'], - doc="The variant to use. other strings not supported unlike stateed in MUI docs.") - componentName = ComponentName(default='ContextfulMUIDivider') diff --git a/hololinked/webdashboard/components/mui/FormControlLabel.py b/hololinked/webdashboard/components/mui/FormControlLabel.py deleted file mode 100644 index ad5139c..0000000 --- a/hololinked/webdashboard/components/mui/FormControlLabel.py +++ /dev/null @@ -1,57 +0,0 @@ -import typing -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import (BooleanProp, UnsupportedProp, NodeProp, ComponentName, - Prop, SelectorProp, ObjectProp) -from ...actions import ActionListProp, BaseAction -from .Radio import Radio - - -class FormControlLabel(MUIBaseComponent): - """ - Visit https://mui.com/material-ui/api/form-control-label/ for React MUI docs - - checked : bool - componentProps : dict[str, Any] - disabled : bool - disableTypography : bool - inputRef : unsupported - label : component - labelPlacement : None - slotProps : dict[str, Any] - onChange : BaseAction - required : bool - value : any - """ - control = NodeProp(class_=(Radio,), default=None, allow_None=True, - doc="control component for the radio") - checked = BooleanProp(default=None, allow_None=True, - doc="If true, the component appears selected.") - componentProps = ObjectProp(doc="The props used for each slot inside") - disabled = BooleanProp(default=None, allow_None=True, - doc="If true, the control is disabled.") - disableTypography = BooleanProp(default=None, allow_None=True, - doc="If true, the label is rendered as it is passed without an additional typography node.") - inputRef = UnsupportedProp(doc="Pass a ref to the input element.") - label = NodeProp(class_=(ReactBaseComponent, str), default=None, allow_None=True, - doc="A text or an element to be used in an enclosing label element.") - labelPlacement = SelectorProp(objects=['bottom', 'end', 'start', 'top'], default='end', allow_None=False, - doc="The position of the label.") - onChange = ActionListProp(default=None, - doc="Callback fired when the state is changed.") - required = BooleanProp(default=None, allow_None=True, - doc="If true, the label will indicate that the input is required.") - slotProps = ObjectProp(doc="The props used for each slot inside.") - value = Prop(default=None, allow_None=True, - doc="The value of the component.") - componentName = ComponentName(default='ContextfulMUIFormControlLabel') - - def __init__(self, id : str, control : ReactBaseComponent, checked : bool = None, classes : dict = None, - componentProps : typing.Dict[str, typing.Any] = None, disabled : bool = None, - disableTypography : bool = None, label : ReactBaseComponent = None, labelPlacement : str = 'end', - onChange : BaseAction = None, required : bool = None, slotProps : typing.Dict[str, typing.Any] = None, - sx : typing.Dict[str, typing.Any] = None, value : str = None) -> None: - super().__init__(id=id, control=control, checked=checked, classes=classes, disabled=disabled, - disableTypography=disableTypography, - label=label, labelPlacement=labelPlacement, onChange=onChange, required=required, sx=sx, - value=value) - diff --git a/hololinked/webdashboard/components/mui/Icon.py b/hololinked/webdashboard/components/mui/Icon.py deleted file mode 100644 index 513afde..0000000 --- a/hololinked/webdashboard/components/mui/Icon.py +++ /dev/null @@ -1,27 +0,0 @@ -from ..basecomponents import MuiBaseComponent -from ..baseprops import StringProp, SelectorProp, ComponentName - - - -class Icon(MuiBaseComponent): - """ - baseClassName : str - color : 'inherit' | 'action' | 'disabled' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - fontSize : 'inherit' | 'large' | 'medium' | 'small' - componentName : read-only - - componentName : "Constant internal value for mapping classes to components in front-end" - baseClassName : "The base class applied to the icon. Defaults to 'material-icons', but can be changed to any other base class that suits the icon font you're using (e.g. material-icons-rounded, fas, etc)." - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - fontSize : "The fontSize applied to the icon. Defaults to 24px, but can be configure to inherit font size." - """ - componentName = ComponentName ( default = "MuiIcon", - doc = "Constant internal value for mapping classes to components in front-end" ) - baseClassName = StringProp ( default = 'material-icons', allow_None = False, - doc = "The base class applied to the icon. Defaults to 'material-icons', but can be changed to any other base class that suits the icon font you're using (e.g. material-icons-rounded, fas, etc)." ) - color = SelectorProp ( objects = [ 'inherit', 'action', 'disabled', 'primary', 'secondary', 'error', 'info', 'success', 'warning'], default = 'inherit', allow_None = False, - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." ) - fontSize = SelectorProp ( objects = [ 'inherit', 'large', 'medium', 'small'], default = 'medium', allow_None = False, - doc = "The fontSize applied to the icon. Defaults to 24px, but can be configure to inherit font size." ) - - diff --git a/hololinked/webdashboard/components/mui/IconButton.py b/hololinked/webdashboard/components/mui/IconButton.py deleted file mode 100644 index fe94904..0000000 --- a/hololinked/webdashboard/components/mui/IconButton.py +++ /dev/null @@ -1,87 +0,0 @@ -from ..basecomponents import MuiBaseComponent, ReactBaseComponent -from ..baseprops import ComponentName, SelectorProp, BooleanProp, StringProp, ChildProp -from ..axios import AxiosHttp -from .Button import ButtonBase - - -class IconButton(ButtonBase): - """ - color : 'inherit' | 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - disabled : bool - disableFocusRipple : bool - disableRipple : bool - edge : 'end' | 'start' | False - size : 'small' | 'medium' | 'large' - centerRipple : bool - disableTouchRipple : bool - focusRipple : bool - focusVisibleClassName : str - LinkComponent : component - onFocusVisible : AxiosHTTP | makeRequest() - TouchRippleProps : dict - - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - disabled : "If true, the component is disabled." - disableFocusRipple : "If true, the keyboard focus ripple is disabled." - disableRipple : "If true, the ripple effect is disabled.⚠️ Without a ripple there is no styling for :focus-visible by default. Be sure to highlight the element by applying separate styles with the .Mui-focusVisible class." - edge : "If given, uses a negative margin to counteract the padding on one side (this is often helpful for aligning the left or right side of the icon with content above or below, without ruining the border size and shape)." - size : "The size of the component. small is equivalent to the dense button styling." - centerRipple : "If true, the ripples are centered. They won't start at the cursor interaction position." - disableTouchRipple : "If true, the touch ripple effect is disabled." - focusRipple : "If true, the base button will have a keyboard focus ripple." - focusVisibleClassName : "This prop can help identify which element has keyboard focus. The class name will be applied when the element gains the focus through keyboard interaction. It's a polyfill for the CSS :focus-visible selector. The rationale for using this feature is explained here. A polyfill can be used to apply a focus-visible class to other components if needed." - LinkComponent : "The component used to render a link when the href prop is provided." - onFocusVisible : "Callback fired when the component is focused with a keyboard. We trigger a onFocus callback too." - TouchRippleProps : "Props applied to the TouchRipple element." - """ - componentName = ComponentName ( default = "MuiIconButton" ) - color = SelectorProp ( objects = [ 'inherit', 'default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'], default = 'default', allow_None = False, - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." ) - disabled = BooleanProp ( default = False, allow_None = False, - doc = "If true, the component is disabled." ) - disableFocusRipple = BooleanProp ( default = False, allow_None = False, - doc = "If true, the keyboard focus ripple is disabled." ) - edge = SelectorProp ( objects = [ 'end', 'start', False], default = False, allow_None = False, - doc = "If given, uses a negative margin to counteract the padding on one side (this is often helpful for aligning the left or right side of the icon with content above or below, without ruining the border size and shape)." ) - size = SelectorProp ( objects = [ 'small', 'medium', 'large'], default = 'medium', allow_None = False, - doc = "The size of the component. small is equivalent to the dense button styling." ) - centerRipple = BooleanProp ( default = False, allow_None = False, - doc = "If true, the ripples are centered. They won't start at the cursor interaction position." ) - - -class HttpIconButton(IconButton): - """ - onClick : AxiosHttp | makeRequest() - color : 'inherit' | 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - disabled : bool - disableFocusRipple : bool - disableRipple : bool - edge : 'end' | 'start' | False - size : 'small' | 'medium' | 'large' - centerRipple : bool - disableTouchRipple : bool - focusRipple : bool - focusVisibleClassName : str - LinkComponent : component - onFocusVisible : AxiosHTTP | makeRequest() - TouchRippleProps : dict - - onClick : "server resource to reach when button is clicked." - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - disabled : "If true, the component is disabled." - disableFocusRipple : "If true, the keyboard focus ripple is disabled." - disableRipple : "If true, the ripple effect is disabled.⚠️ Without a ripple there is no styling for :focus-visible by default. Be sure to highlight the element by applying separate styles with the .Mui-focusVisible class." - edge : "If given, uses a negative margin to counteract the padding on one side (this is often helpful for aligning the left or right side of the icon with content above or below, without ruining the border size and shape)." - size : "The size of the component. small is equivalent to the dense button styling." - centerRipple : "If true, the ripples are centered. They won't start at the cursor interaction position." - disableTouchRipple : "If true, the touch ripple effect is disabled." - focusRipple : "If true, the base button will have a keyboard focus ripple." - focusVisibleClassName : "This prop can help identify which element has keyboard focus. The class name will be applied when the element gains the focus through keyboard interaction. It's a polyfill for the CSS :focus-visible selector. The rationale for using this feature is explained here. A polyfill can be used to apply a focus-visible class to other components if needed." - LinkComponent : "The component used to render a link when the href prop is provided." - onFocusVisible : "Callback fired when the component is focused with a keyboard. We trigger a onFocus callback too." - TouchRippleProps : "Props applied to the TouchRipple element." - """ - componentName = ComponentName ( default = "MuiHttpIconButton" ) - onClick = ChildProp ( class_ = AxiosHttp, default = None, allow_None = True, - doc = "server resource to reach when button is clicked." ) - diff --git a/hololinked/webdashboard/components/mui/Layout.py b/hololinked/webdashboard/components/mui/Layout.py deleted file mode 100644 index 2e5801f..0000000 --- a/hololinked/webdashboard/components/mui/Layout.py +++ /dev/null @@ -1,23 +0,0 @@ -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import TupleSelectorProp, NodeProp, NumberProp, BooleanProp, ComponentName - - - -class Stack(MUIBaseComponent): - direction = TupleSelectorProp(objects=['column-reverse', 'column', 'row-reverse', 'row'], default="column", - doc="Defines the flex-direction style property. It is applied for all screen sizes.", - accept_list=True) - divider = NodeProp(class_=ReactBaseComponent, default=None, allow_None=True, - doc="Add an element between each child.") - spacing = NumberProp(bounds=(0, None), - doc="Defines the space between immediate children, accepts only number unlike stated in MUI docs") - useFlexGap = BooleanProp(default=False, - doc="If true, the CSS flexbox gap is used instead of applying margin to children. not supported on all browsers.") - componentName = ComponentName(default="ContextfulMUIStack") - - -class Box(MUIBaseComponent): - componentName = ComponentName(default="ContextfulMUIBox") - - - \ No newline at end of file diff --git a/hololinked/webdashboard/components/mui/MaterialIcon.py b/hololinked/webdashboard/components/mui/MaterialIcon.py deleted file mode 100644 index 9215382..0000000 --- a/hololinked/webdashboard/components/mui/MaterialIcon.py +++ /dev/null @@ -1,47 +0,0 @@ -from ..basecomponents import MuiBaseComponent -from ..baseprops import ComponentName, SelectorProp, StringProp, BooleanProp -from .MuiConstants import AllIcons - - - -class MaterialIcon(MuiBaseComponent): - """ - IconName : check MUI webpage - https://mui.com/material-ui/material-icons/ - color : 'inherit' | 'action' | 'disabled' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - fontSize : 'inherit' | 'large' | 'medium' | 'small' - htmlColor : str - inheritViewBox : bool - shapeRendering : str - titleAccess : str - viewBox : str - componentName : read-only - - IconName : "Specify one of the icon names in given in https://mui.com/material-ui/material-icons/ and that icon with rendered." - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide. You can use the htmlColor prop to apply a color attribute to the SVG element." - fontSize : "The fontSize applied to the icon. Defaults to 24px, but can be configure to inherit font size." - htmlColor : "Applies a color attribute to the SVG element." - inheritViewBox : "If true, the root node will inherit the custom component's viewBox and the viewBox prop will be ignored. Useful when you want to reference a custom component and have SvgIcon pass that component's viewBox to the root node." - shapeRendering : "The shape-rendering attribute. The behavior of the different options is described on the MDN Web Docs. If you are having issues with blurry icons you should investigate this prop." - titleAccess : "Provides a human-readable title for the element that contains it. https://www.w3.org/TR/SVG-access/#Equivalent" - viewBox : "Allows you to redefine what the coordinates without units mean inside an SVG element. For example, if the SVG element is 500 (width) by 200 (height), and you pass viewBox='0 0 50 20', this means that the coordinates inside the SVG will go from the top left corner (0,0) to bottom right (50,20) and each unit will be worth 10px." - componentName : "Constant internal value for mapping classes to components in front-end" - """ - color = SelectorProp ( objects = [ 'inherit', 'action', 'disabled', 'primary', 'secondary', 'error', 'info', 'success', 'warning'], default = 'inherit', allow_None = False, - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide. You can use the htmlColor prop to apply a color attribute to the SVG element." ) - fontSize = SelectorProp ( objects = [ 'inherit', 'large', 'medium', 'small'], default = 'medium', allow_None = False, - doc = "The fontSize applied to the icon. Defaults to 24px, but can be configure to inherit font size." ) - htmlColor = StringProp ( default = None, allow_None = True, - doc = "Applies a color attribute to the SVG element." ) - inheritViewBox = BooleanProp ( default = False, allow_None = False, - doc = "If true, the root node will inherit the custom component's viewBox and the viewBox prop will be ignored. Useful when you want to reference a custom component and have SvgIcon pass that component's viewBox to the root node." ) - shapeRendering = StringProp ( default = None, allow_None = True, - doc = "The shape-rendering attribute. The behavior of the different options is described on the MDN Web Docs. If you are having issues with blurry icons you should investigate this prop." ) - titleAccess = StringProp ( default = None, allow_None = True, - doc = "Provides a human-readable title for the element that contains it. https://www.w3.org/TR/SVG-access/#Equivalent" ) - viewBox = StringProp ( default = '0 0 24 24', allow_None = False, - doc = "Allows you to redefine what the coordinates without units mean inside an SVG element. For example, if the SVG element is 500 (width) by 200 (height), and you pass viewBox='0 0 50 20', this means that the coordinates inside the SVG will go from the top left corner (0,0) to bottom right (50,20) and each unit will be worth 10px." ) - componentName = ComponentName ( default = "MuiMaterialIcon", - doc = "Constant internal value for mapping classes to components in front-end" ) - IconName = SelectorProp ( objects = AllIcons, default = AllIcons[0], allow_None = False, - doc = "Specify one of the icon names in given in https://mui.com/material-ui/material-icons/ and that icon with rendered." ) - diff --git a/hololinked/webdashboard/components/mui/MuiConstants.py b/hololinked/webdashboard/components/mui/MuiConstants.py deleted file mode 100644 index 686e668..0000000 --- a/hololinked/webdashboard/components/mui/MuiConstants.py +++ /dev/null @@ -1,2 +0,0 @@ -# Add constants here specific to MUI -AllIcons = ['RefreshIcon', 'SendIcon', 'CompareArrows'] \ No newline at end of file diff --git a/hololinked/webdashboard/components/mui/Radio.py b/hololinked/webdashboard/components/mui/Radio.py deleted file mode 100644 index 5e62d5e..0000000 --- a/hololinked/webdashboard/components/mui/Radio.py +++ /dev/null @@ -1,67 +0,0 @@ -import typing -from .Button import ButtonBase -from ...basecomponents import ReactBaseComponent, MUIBaseComponent -from ...baseprops import (StringProp, SelectorProp, ComponentName, NumberProp, - Prop, IntegerProp, BooleanProp, ObjectProp, NodeProp, UnsupportedProp) -from ...actions import ActionListProp, BaseAction - - - -class Radio(ButtonBase): - """ - Visit https://mui.com/material-ui/api/radio/ for React MUI docs - - checked : bool - checkedIcon : component - color : None - disabled : bool - disableRipple : bool - icon : component - id : str - inputProps : dict - inputRef : unsupported - name : str - onChange : BaseAction - required : bool - size : None - value : any - """ - checked = BooleanProp(default=None, allow_None=True, - doc="If true, the component is checked.") - checkedIcon = NodeProp(class_=(ReactBaseComponent, str), default=None, allow_None=True, - doc="The icon to display when the component is checked.") - color = SelectorProp(objects=['default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'], - default='primary', allow_None=False, - doc="The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide.") - disabled = BooleanProp(default=None, allow_None=True, - doc="If true, the component is disabled.") - disableRipple = BooleanProp(default=False, allow_None=False, - doc="If true, the ripple effect is disabled.") - icon = NodeProp(class_=(ReactBaseComponent, str), default=None, allow_None=True, - doc="The icon to display when the component is unchecked.") - id = StringProp(default=None, allow_None=True, - doc="The id of the input element.") - inputProps = ObjectProp(default=None, allow_None=True, - doc="Attributes applied to the input element.") - inputRef = UnsupportedProp() - name = StringProp(default=None, allow_None=True, - doc="Name attribute of the input element.") - onChange = ActionListProp(default=None, - doc="Callback fired when the state is changed.") - required = BooleanProp(default=False, allow_None=False, - doc="If true, the input element is required.") - size = SelectorProp(objects=['medium', 'small'], default='medium', allow_None=False, - doc="The size of the component. small is equivalent to the dense radio styling.") - value = Prop(default=None, - doc="The value of the component. The DOM API casts this to a string.") - componentName = ComponentName(default='ContextfulMUIRadio') - - def __init__(self, id : str, checked : bool = None, checkedIcon : ReactBaseComponent = None, - classes : dict = None, color : str = 'primary', disabled : bool = None, disableRipple : bool = False, - icon : ReactBaseComponent = None, inputProps : dict = None, - name : str = None, onChange : BaseAction = None, required : bool = False, size : str = 'medium', - sx : typing.Dict[str, typing.Any] = None, value : str = None) -> None: - super().__init__(id=id, checked=checked, checkedIcon=checkedIcon, classes=classes, color=color, - disabled=disabled, disableRipple=disableRipple, icon=icon, inputProps=inputProps, - name=name, onChange=onChange, required=required, size=size, sx=sx, value=value) - diff --git a/hololinked/webdashboard/components/mui/RadioGroup.py b/hololinked/webdashboard/components/mui/RadioGroup.py deleted file mode 100644 index 1a1ae76..0000000 --- a/hololinked/webdashboard/components/mui/RadioGroup.py +++ /dev/null @@ -1,43 +0,0 @@ -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import (NodeProp, ComponentName, Prop, IntegerProp, NumberProp, - StringProp, BooleanProp, StubProp) -from ...actions import ActionListProp, ComponentOutput -from ...valuestub import StringStub - - - -class FormGroup(MUIBaseComponent): - """ - Visit https://mui.com/material-ui/api/form-group/ for React MUI docs - - row : bool - """ - row = BooleanProp(default=False, allow_None=False, - doc="Display group of elements in a compact row.") - componentName = ComponentName(default="ContextfulMUIFormGroup") - - - -class RadioGroup(FormGroup): - """ - Visit https://mui.com/material-ui/api/radio-group/ for React MUI docs - defaultValue : any - name : str - onChange : list of actions, please only append after init. - value : stub - """ - defaultValue = Prop(default=None, - doc="The default value. Use when the component is not controlled.") - name = StringProp(default=None, allow_None=True, - doc="The name used to reference the value of the control. \ - If you don't provide this prop, it falls back to a randomly generated name.") - onChange = ActionListProp(default=None, - doc="Callback fired when a radio button is selected.") - value = StubProp(default=None, - doc="Value of the selected radio button. The DOM API casts this to a string.") - componentName = ComponentName(default="ContextfulMUIRadioGroup") - - def __init__(self, id: str, **params): - self.value = StringStub() - super().__init__(id=id, **params) - self.onChange = [ComponentOutput(outputID=self.action_id)] + params.get('onChange', []) \ No newline at end of file diff --git a/hololinked/webdashboard/components/mui/Slider.py b/hololinked/webdashboard/components/mui/Slider.py deleted file mode 100644 index 0157568..0000000 --- a/hololinked/webdashboard/components/mui/Slider.py +++ /dev/null @@ -1,118 +0,0 @@ -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import ComponentName, StringProp, SelectorProp, BooleanProp, NodeProp, NumberProp, ObjectProp, TypedDict -from ...axios import RequestProp - - - -class Slider(MUIBaseComponent): - """ - aria-label : str - aria-labelledby : str - aria-valuetext : str - componentsProps : { input?: object, mark?: object, markLabel?: object, rail?: object, root?: object, thumb?: object, track?: object, valueLabel?: { className?: string, components?: { Root?: elementType }, style?: object, value?: Array | number, valueLabelDisplay?: 'auto' | 'off' | 'on' } } - defaultValue : TypeConstrainedList | float - disabled : bool - disableSwap : bool - getAriaLabel : AxiosHttp | makeRequest() - getAriaValueText : AxiosHttp | makeRequest() - isRtl : bool - marks : TypeConstrainedList | bool - max : float - min : float - name : str - onChange : AxiosHttp | makeRequest() - onChangeCommitted : AxiosHttp | makeRequest() - orientation : 'horizontal' | 'vertical' - scale : AxiosHttp | makeRequest() - step : float - tabIndex : float - track : 'inverted' | 'normal' | false - value : TypeConstrainedList | float - valueLabelDisplay : 'auto' | 'off' | 'on' - valueLabelFormat : MethodsType | str - componentName : read-only - - aria-label : "The label of the slider." - aria-labelledby : "The id of the element containing a label for the slider." - aria-valuetext : "A string value that provides a user-friendly name for the current value of the slider." - componentsProps : "The props used for each slot inside the Slider." - defaultValue : "The default value. Use when the component is not controlled." - disabled : "If true, the component is disabled." - disableSwap : "If true, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb." - getAriaLabel : "Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. This is important for screen reader users.Signature:function(index: number) => stringindex: The thumb label's index to format." - getAriaValueText : "Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider. This is important for screen reader users.Signature:function(value: number, index: number) => stringvalue: The thumb label's value to format.index: The thumb label's index to format." - isRtl : "Indicates whether the theme context has rtl direction. It is set automatically." - marks : "Marks indicate predetermined values to which the user can move the slider. If true the marks are spaced according the value of the step prop. If an array, it should contain objects with value and an optional label keys." - max : "The maximum allowed value of the slider. Should not be equal to min." - min : "The minimum allowed value of the slider. Should not be equal to max." - name : "Name attribute of the hidden input element." - onChange : "Callback function that is fired when the slider's value changed.Signature:function(event: Event, value: number | Array, activeThumb: number) => voidevent: The event source of the callback. You can pull out the new value by accessing event.target.value (any). Warning: This is a generic event not a change event.value: The new value.activeThumb: Index of the currently moved thumb." - onChangeCommitted : "Callback function that is fired when the mouseup is triggered.Signature:function(event: React.SyntheticEvent | Event, value: number | Array) => voidevent: The event source of the callback. Warning: This is a generic event not a change event.value: The new value." - orientation : "The component orientation." - scale : "A transformation function, to change the scale of the slider." - step : "The granularity with which the slider can step through values. (A "discrete" slider.) The min prop serves as the origin for the valid values. We recommend (max - min) to be evenly divisible by the step.When step is null, the thumb can only be slid onto marks provided with the marks prop." - tabIndex : "Tab index attribute of the hidden input element." - track : "The track presentation:- normal the track will render a bar representing the slider value. - inverted the track will render a bar representing the remaining slider value. - false the track will render without a bar." - value : "The value of the slider. For ranged sliders, provide an array with two values." - valueLabelDisplay : "Controls when the value label is displayed:- auto the value label will display when the thumb is hovered or focused. - on will display persistently. - off will never display." - valueLabelFormat : "The format function the value label's value.When a function is provided, it should have the following signature:- {number} value The value label's value to format - {number} index The value label's index to format" - componentName : "Constant internal value for mapping classes to components in front-end" - """ - ariaLabel = StringProp ( default = None, allow_None = True, - doc = "The label of the slider." ) - ariaLabelledBy = StringProp ( default = None, allow_None = True, - doc = "The id of the element containing a label for the slider." ) - ariaValueText = StringProp ( default = None, allow_None = True, - doc = "A string value that provides a user-friendly name for the current value of the slider." ) - color = SelectorProp ( objects = [ 'primary', 'secondary'], default = 'primary', allow_None = False, - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." ) - componentsProps = ObjectProp ( default = {}, - doc = "The props used for each slot inside the Slider." ) - defaultValue = SelectorProp ( class_ = (list, float), default = None, allow_None = True, - doc = "The default value. Use when the component is not controlled." ) - disabled = BooleanProp ( default = False, - doc = "If true, the component is disabled." ) - disableSwap = BooleanProp ( default = False, - doc = "If true, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb." ) - getAriaLabel = NodeProp ( class_ = ReactBaseComponent, default = None, allow_None = True, - doc = "Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. This is important for screen reader users.Signature:function(index: number) => stringindex: The thumb label's index to format." ) - getAriaValueText = NodeProp ( class_ = ReactBaseComponent, default = None, allow_None = True, - doc = "Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider. This is important for screen reader users.Signature:function(value: number, index: number) => stringvalue: The thumb label's value to format.index: The thumb label's index to format." ) - isRtl = BooleanProp ( default = False, - doc = "Indicates whether the theme context has rtl direction. It is set automatically." ) - marks = SelectorProp ( class_ = (list, bool), default = False, - doc = "Marks indicate predetermined values to which the user can move the slider. If true the marks are spaced according the value of the step prop. If an array, it should contain objects with value and an optional label keys." ) - max = NumberProp ( default = 100, - doc = "The maximum allowed value of the slider. Should not be equal to min." ) - min = NumberProp ( default = 0, - doc = "The minimum allowed value of the slider. Should not be equal to max." ) - name = StringProp ( default = None, allow_None = True, - doc = "Name attribute of the hidden input element." ) - onChange = RequestProp ( default = None, - doc = "Callback function that is fired when the slider's value changed.Signature:function(event: Event, value: number | Array, activeThumb: number) => voidevent: The event source of the callback. You can pull out the new value by accessing event.target.value (any). Warning: This is a generic event not a change event.value: The new value.activeThumb: Index of the currently moved thumb." ) - onChangeCommitted = RequestProp ( default = None, - doc = "Callback function that is fired when the mouseup is triggered.Signature:function(event: React.SyntheticEvent | Event, value: number | Array) => voidevent: The event source of the callback. Warning: This is a generic event not a change event.value: The new value." ) - orientation = SelectorProp ( objects = ['horizontal', 'vertical'], default = 'horizontal', - doc = "The component orientation." ) - scale = RequestProp ( default = None, - doc = "A transformation function, to change the scale of the slider." ) - size = SelectorProp ( objects = ['small', 'medium'], default = 'medium', - doc = "The size of the slider." ) - slots = TypedDict ( default = {}, allow_None = False, key_type = str, item_type = (str, ReactBaseComponent), - doc = "The components used for each slot inside the Slider. Either a string to use a HTML element or a component" ) - slotProps = ObjectProp ( default = {}, allow_None = False, - doc = "The props used for each slot inside the Slider.") - step = NumberProp ( default = 1, allow_None = False, - doc = "The granularity with which the slider can step through values. (A discrete slider.) The min prop serves as the origin for the valid values. We recommend (max - min) to be evenly divisible by the step.When step is null, the thumb can only be slid onto marks provided with the marks prop." ) - tabIndex = NumberProp ( default = None, allow_None = True, - doc = "Tab index attribute of the hidden input element." ) - track = SelectorProp ( objects = ['inverted', 'normal', False], default = 'normal', - doc = "The track presentation:- normal the track will render a bar representing the slider value. - inverted the track will render a bar representing the remaining slider value. - false the track will render without a bar." ) - value = SelectorProp ( class_ = (list, float), default = None, allow_None = True, - doc = "The value of the slider. For ranged sliders, provide an array with two values." ) - valueLabelDisplay = SelectorProp ( objects = [ 'auto', 'off', 'on'], default = 'off', - doc = "Controls when the value label is displayed:- auto the value label will display when the thumb is hovered or focused. - on will display persistently. - off will never display." ) - valueLabelFormat = RequestProp ( default = None, - doc = "The format function the value label's value.When a function is provided, it should have the following signature:- {number} value The value label's value to format - {number} index The value label's index to format" ) - componentName = ComponentName ( default = "MuiSlider", - doc = "Constant internal value for mapping classes to components in front-end" ) diff --git a/hololinked/webdashboard/components/mui/SvgIcon.py b/hololinked/webdashboard/components/mui/SvgIcon.py deleted file mode 100644 index 1775805..0000000 --- a/hololinked/webdashboard/components/mui/SvgIcon.py +++ /dev/null @@ -1,43 +0,0 @@ -from ..basecomponents import MuiBaseComponent, ReactBaseComponent -from ..baseprops import ComponentNameProp, AnyValueProp, MultiTypeProp, SelectorProp, StringProp, BooleanProp - - - -class SvgIcon(MuiBaseComponent): - """ - color : 'inherit' | 'action' | 'disabled' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - fontSize : 'inherit' | 'large' | 'medium' | 'small' - htmlColor : str - inheritViewBox : bool - shapeRendering : str - titleAccess : str - viewBox : str - componentName : read-only - - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide. You can use the htmlColor prop to apply a color attribute to the SVG element." - fontSize : "The fontSize applied to the icon. Defaults to 24px, but can be configure to inherit font size." - htmlColor : "Applies a color attribute to the SVG element." - inheritViewBox : "If true, the root node will inherit the custom component's viewBox and the viewBox prop will be ignored. Useful when you want to reference a custom component and have SvgIcon pass that component's viewBox to the root node." - shapeRendering : "The shape-rendering attribute. The behavior of the different options is described on the MDN Web Docs. If you are having issues with blurry icons you should investigate this prop." - titleAccess : "Provides a human-readable title for the element that contains it. https://www.w3.org/TR/SVG-access/#Equivalent" - viewBox : "Allows you to redefine what the coordinates without units mean inside an SVG element. For example, if the SVG element is 500 (width) by 200 (height), and you pass viewBox='0 0 50 20', this means that the coordinates inside the SVG will go from the top left corner (0,0) to bottom right (50,20) and each unit will be worth 10px." - componentName : "Constant internal value for mapping classes to components in front-end" - """ - color = SelectorProp ( objects = [ 'inherit', 'action', 'disabled', 'primary', 'secondary', 'error', 'info', 'success', 'warning'], default = 'inherit', allow_None = False, - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide. You can use the htmlColor prop to apply a color attribute to the SVG element." ) - fontSize = SelectorProp ( objects = [ 'inherit', 'large', 'medium', 'small'], default = 'medium', allow_None = False, - doc = "The fontSize applied to the icon. Defaults to 24px, but can be configure to inherit font size." ) - htmlColor = StringProp ( default = None, allow_None = True, - doc = "Applies a color attribute to the SVG element." ) - inheritViewBox = BooleanProp ( default = False, allow_None = False, - doc = "If true, the root node will inherit the custom component's viewBox and the viewBox prop will be ignored. Useful when you want to reference a custom component and have SvgIcon pass that component's viewBox to the root node." ) - shapeRendering = StringProp ( default = None, allow_None = True, - doc = "The shape-rendering attribute. The behavior of the different options is described on the MDN Web Docs. If you are having issues with blurry icons you should investigate this prop." ) - titleAccess = StringProp ( default = None, allow_None = True, - doc = "Provides a human-readable title for the element that contains it. https://www.w3.org/TR/SVG-access/#Equivalent" ) - viewBox = StringProp ( default = '0 0 24 24', allow_None = False, - doc = "Allows you to redefine what the coordinates without units mean inside an SVG element. For example, if the SVG element is 500 (width) by 200 (height), and you pass viewBox='0 0 50 20', this means that the coordinates inside the SVG will go from the top left corner (0,0) to bottom right (50,20) and each unit will be worth 10px." ) - componentName = ComponentNameProp ( default = "MuiSvgIcon", - doc = "Constant internal value for mapping classes to components in front-end" ) - - diff --git a/hololinked/webdashboard/components/mui/Switch.py b/hololinked/webdashboard/components/mui/Switch.py deleted file mode 100644 index 5a1dc73..0000000 --- a/hololinked/webdashboard/components/mui/Switch.py +++ /dev/null @@ -1,70 +0,0 @@ -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import ComponentName, StubProp, BooleanProp, NodeProp, SelectorProp, StringProp, ObjectProp, UnsupportedProp -from ...axios import RequestProp -from ...valuestub import BooleanStub - - -class Switch(MUIBaseComponent): - """ - checked : bool - checkedIcon : component - color : 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - defaultChecked : bool - disabled : bool - disableRipple : bool - edge : 'end' | 'start' | false - icon : component - id : str - inputProps : dict - onChange : AxiosHttp | makeRequest() - required : bool - size : 'medium' | 'small' - value : any - componentName : read-only - - checked : "If true, the component is checked." - checkedIcon : "The icon to display when the component is checked." - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - defaultChecked : "The default checked state. Use when the component is not controlled." - disabled : "If true, the component is disabled." - disableRipple : "If true, the ripple effect is disabled." - edge : "If given, uses a negative margin to counteract the padding on one side (this is often helpful for aligning the left or right side of the icon with content above or below, without ruining the border size and shape)." - icon : "The icon to display when the component is unchecked." - id : "The id of the input element." - inputProps : "Attributes applied to the input element." - onChange : "Callback fired when the state is changed.Signature:function(event: React.ChangeEvent) => voidevent: The event source of the callback. You can pull out the new value by accessing event.target.value (string). You can pull out the new checked state by accessing event.target.checked (boolean)." - required : "If true, the input element is required." - size : "The size of the component. small is equivalent to the dense switch styling." - value : "The value of the component. The DOM API casts this to a string. The browser uses 'on' as the default value." - componentName : "Constant internal value for mapping classes to components in front-end" - """ - checked = BooleanProp ( default = False, - doc = "If true, the component is checked. Note - this is an input value and different from defaultChecked." ) - checkedIcon = NodeProp ( class_ = ReactBaseComponent, default = None, allow_None = True, - doc = "The icon to display when the component is checked." ) - color = SelectorProp ( objects = [ 'default', 'primary', 'secondary', 'error', 'info', 'success', 'warning'], default = 'primary', - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." ) - defaultChecked = BooleanProp ( default = False, - doc = "The default checked state. Use when the component is not controlled." ) - disabled = BooleanProp ( default = False, - doc = "If true, the component is disabled." ) - disableRipple = BooleanProp ( default = False, - doc = "If true, the ripple effect is disabled." ) - edge = SelectorProp ( objects = [ 'end', 'start', False], default = False, - doc = "If given, uses a negative margin to counteract the padding on one side (this is often helpful for aligning the left or right side of the icon with content above or below, without ruining the border size and shape)." ) - icon = NodeProp ( class_ = ReactBaseComponent, default = None, allow_None = True, - doc = "The icon to display when the component is unchecked." ) - inputElementID = StringProp ( default = None, allow_None = True, - doc = "The id of the input element." ) - inputProps = ObjectProp ( doc = "Attributes applied to the input element." ) - inputRef = UnsupportedProp ( doc = "This prop is not supported as it generally executes a client side function") - onChange = RequestProp ( doc = "Request fired when the state is changed. To use the boolean value within the UI, use the `value` prop" ) - required = BooleanProp ( default = False, - doc = "If true, the input element is required." ) - size = SelectorProp ( objects = [ 'medium', 'small'], default = 'medium', - doc = "The size of the component. small is equivalent to the dense switch styling." ) - value = StubProp ( default = BooleanStub(), - doc = "The value of the component. The DOM API casts this to a string. The browser uses 'on' as the default value." ) - componentName = ComponentName ( default = "MuiSwitch" ) - - diff --git a/hololinked/webdashboard/components/mui/TextField.py b/hololinked/webdashboard/components/mui/TextField.py deleted file mode 100644 index eca1cee..0000000 --- a/hololinked/webdashboard/components/mui/TextField.py +++ /dev/null @@ -1,172 +0,0 @@ - -from ....param import Parameter -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import ComponentName, StringProp, BooleanProp, SelectorProp, NodeProp, IntegerProp, StubProp, ObjectProp -from ...valuestub import StringStub -from ...actions import ComponentOutputProp - - - -class FormControl(MUIBaseComponent): - """ - color : 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - disabled : bool - error : bool - focused : bool - fullWidth : bool - hiddenLabel : bool - margin : 'dense' | 'none' | 'normal' - required : bool - size : 'medium' | 'small' - variant : 'filled' | 'outlined' | 'standard' - componentName : read-only - - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - disabled : "If true, the label, input and helper text should be displayed in a disabled state." - error : "If true, the label is displayed in an error state." - focused : "If true, the component is displayed in focused state." - fullWidth : "If true, the component will take up the full width of its container." - hiddenLabel : "If true, the label is hidden. This is used to increase density for a FilledInput. Be sure to add aria-label to the input element." - margin : "If dense or normal, will adjust vertical spacing of this and contained components." - required : "If true, the label will indicate that the input is required." - size : "The size of the component." - variant : "The variant to use." - componentName : "Constant internal value for mapping classes to components in front-end" - """ - color = SelectorProp ( objects = ['primary', 'secondary', 'error', 'info', 'success', 'warning'], default = 'primary', - doc = "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." ) - disabled = BooleanProp ( default = False, - doc = "If true, the label, input and helper text should be displayed in a disabled state." ) - error = BooleanProp ( default = False, - doc = "If true, the label is displayed in an error state." ) - focused = BooleanProp ( default = False, - doc = "If true, the component is displayed in focused state." ) - fullWidth = BooleanProp ( default = False, - doc = "If true, the component will take up the full width of its container." ) - hiddenLabel = BooleanProp ( default = False, - doc = "If true, the label is hidden. This is used to increase density for a FilledInput. Be sure to add aria-label to the input element." ) - margin = SelectorProp ( objects = ['dense', 'none', 'normal'], default = 'none', - doc = "If dense or normal, will adjust vertical spacing of this and contained components." ) - required = BooleanProp ( default = False, - doc = "If true, the label will indicate that the input is required." ) - size = SelectorProp ( objects = [ 'medium', 'small'], default = 'medium', - doc = "The size of the component." ) - variant = SelectorProp ( objects = [ 'filled', 'outlined', 'standard'], default = 'outlined', - doc = "The variant to use." ) - componentName = ComponentName ( default = "FormControl" ) - - -class TextField(FormControl): - """ - autoComplete : str - autoFocus : bool - color : 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' - defaultValue : any - disabled : bool - error : bool - FormHelperTextProps : dict - fullWidth : bool - helperText : component - id : str - InputLabelProps : dict - inputProps : dict - InputProps : dict - label : component - margin : 'dense' | 'none' | 'normal' - maxRows : float | str - minRows : float | str - multiline : bool - name : str - onChange : AxiosHttp | makeRequest() - placeholder : str - required : bool - rows : float | str - select : bool - SelectProps : dict - size : 'medium' | 'small' - type : str - value : any - variant : 'filled' | 'outlined' | 'standard' - focused : bool - hiddenLabel : bool - componentName : read-only - - autoComplete : "This prop helps users to fill forms faster, especially on mobile devices. The name can be confusing, as it's more like an autofill. You can learn more about it following the specification." - autoFocus : "If true, the input element is focused during the first mount." - color : "The color of the component. It supports both default and custom theme colors, which can be added as shown in the palette customization guide." - defaultValue : "The default value. Use when the component is not controlled." - disabled : "If true, the component is disabled." - error : "If true, the label is displayed in an error state." - FormHelperTextProps : "Props applied to the FormHelperText element." - fullWidth : "If true, the input will take up the full width of its container." - helperText : "The helper text content." - id : "The id of the input element. Use this prop to make label and helperText accessible for screen readers." - InputLabelProps : "Props applied to the InputLabel element. Pointer events like onClick are enabled if and only if shrink is true." - inputProps : "Attributes applied to the input element." - InputProps : "Props applied to the Input element. It will be a FilledInput, OutlinedInput or Input component depending on the variant prop value." - label : "The label content." - margin : "If dense or normal, will adjust vertical spacing of this and contained components." - maxRows : "Maximum number of rows to display when multiline option is set to true." - minRows : "Minimum number of rows to display when multiline option is set to true." - multiline : "If true, a textarea element is rendered instead of an input." - name : "Name attribute of the input element." - onChange : "Callback fired when the value is changed.Signature:function(event: object) => voidevent: The event source of the callback. You can pull out the new value by accessing event.target.value (string)." - placeholder : "The short hint displayed in the input before the user enters a value." - required : "If true, the label is displayed as required and the input element is required." - rows : "Number of rows to display when multiline option is set to true." - select : "Render a Select element while passing the Input element to Select as input parameter. If this option is set you must pass the options of the select as children." - SelectProps : "Props applied to the Select element." - size : "The size of the component." - type : "Type of the input element. It should be a valid HTML5 input type." - value : "The value of the input element, required for a controlled component." - variant : "The variant to use." - focused : "If true, the component is displayed in focused state." - hiddenLabel : "If true, the label is hidden. This is used to increase density for a FilledInput. Be sure to add aria-label to the input element." - componentName : "Constant internal value for mapping classes to components in front-end" - """ - autoComplete = StringProp ( default = None, allow_None = True, - doc = "This prop helps users to fill forms faster, especially on mobile devices. The name can be confusing, as it's more like an autofill. You can learn more about it following the specification." ) - autoFocus = BooleanProp ( default = False, - doc = "If true, the input element is focused during the first mount." ) - defaultValue = Parameter ( default = None, allow_None = True, - doc = "The default value. Use when the component is not controlled." ) - FormHelperTextProps = ObjectProp ( default = None, - doc = "Props applied to the FormHelperText element." ) - helperText = NodeProp ( class_ = (ReactBaseComponent, str), default = None, allow_None = True, - doc = "The helper text content." ) - InputLabelProps = ObjectProp ( default = None, allow_None = True, - doc = "Props applied to the InputLabel element. Pointer events like onClick are enabled if and only if shrink is true." ) - inputProps = ObjectProp ( default = {}, doc = "Attributes applied to the input element." ) - InputProps = ObjectProp ( default = None, allow_None = True, - doc = "Props applied to the Input element. It will be a FilledInput, OutlinedInput or Input component depending on the variant prop value." ) - label = NodeProp ( class_ = (ReactBaseComponent, str), default = None, allow_None = True, - doc = "The label content." ) - maxRows = IntegerProp ( default = None, allow_None = True, - doc = "Maximum number of rows to display when multiline option is set to true." ) - minRows = IntegerProp ( default = None, allow_None = True, - doc = "Minimum number of rows to display when multiline option is set to true." ) - multiline = BooleanProp ( default = False, - doc = "If true, a textarea element is rendered instead of an input." ) - name = StringProp ( default = None, allow_None = True, - doc = "Name attribute of the input element." ) - onChange = ComponentOutputProp() - placeholder = StringProp ( default = None, allow_None = True, - doc = "The short hint displayed in the input before the user enters a value." ) - required = BooleanProp ( default = False, - doc = "If true, the label is displayed as required and the input element is required." ) - rows = IntegerProp ( default = None, allow_None = True, - doc = "Number of rows to display when multiline option is set to true." ) - select = BooleanProp ( default = False, - doc = "Render a Select element while passing the Input element to Select as input parameter. If this option is set you must pass the options of the select as children." ) - SelectProps = ObjectProp ( default = None, - doc = "Props applied to the Select element." ) - componentName = ComponentName ( default = "ContextfulMUITextField" ) - value = StubProp (doc = """The value of the own element (symbolic JSON specification based pointer - the actual value is in the browser). - For textfield, this is the content of the textfield.""") - - def __init__(self, **params): - self.inputProps = {} # above initialisation is a shared dict - self.value = StringStub() - super().__init__(**params) - - \ No newline at end of file diff --git a/hololinked/webdashboard/components/mui/Typography.py b/hololinked/webdashboard/components/mui/Typography.py deleted file mode 100644 index fa9ec00..0000000 --- a/hololinked/webdashboard/components/mui/Typography.py +++ /dev/null @@ -1,38 +0,0 @@ -from ...basecomponents import MUIBaseComponent, ReactBaseComponent -from ...baseprops import ComponentName, AnyValueProp, MultiTypeProp, SelectorProp, BooleanProp, DictProp - - - -class Typography(MUIBaseComponent): - """ - align : 'center' | 'inherit' | 'justify' | 'left' | 'right' - gutterBottom : bool - noWrap : bool - paragraph : bool - variant : 'body1' | 'body2' | 'button' | 'caption' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'inherit' | 'overline' | 'subtitle1' | 'subtitle2' - variantMapping : dict - componentName : read-only - - align : "Set the text-align on the component." - gutterBottom : "If true, the text will have a bottom margin." - noWrap : "If true, the text will not wrap, but instead will truncate with a text overflow ellipsis.Note that text overflow can only happen with block or inline-block level elements (the element needs to have a width in order to overflow)." - paragraph : "If true, the element will be a paragraph element." - variant : "Applies the theme typography styles." - variantMapping : "The component maps the variant prop to a range of different HTML element types. For instance, subtitle1 to
. If you wish to change that mapping, you can provide your own. Alternatively, you can use the component prop." - componentName : "Constant internal value for mapping classes to components in front-end" - """ - align = SelectorProp ( objects = [ 'center', 'inherit', 'justify', 'left', 'right'], default = 'inherit', allow_None = False, - doc = "Set the text-align on the component." ) - gutterBottom = BooleanProp ( default = False, allow_None = False, - doc = "If true, the text will have a bottom margin." ) - noWrap = BooleanProp ( default = False, allow_None = False, - doc = "If true, the text will not wrap, but instead will truncate with a text overflow ellipsis.Note that text overflow can only happen with block or inline-block level elements (the element needs to have a width in order to overflow)." ) - paragraph = BooleanProp ( default = False, allow_None = False, - doc = "If true, the element will be a paragraph element." ) - variant = SelectorProp ( objects = [ 'body1', 'body2', 'button', 'caption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'inherit', 'overline', 'subtitle1', 'subtitle2'], default = 'body1', allow_None = False, - doc = "Applies the theme typography styles." ) - variantMapping = DictProp ( default = None, key_type = str, allow_None = True, - doc = "The component maps the variant prop to a range of different HTML element types. For instance, subtitle1 to
. If you wish to change that mapping, you can provide your own. Alternatively, you can use the component prop." ) - componentName = ComponentName ( default = "MuiTypography" ) - - diff --git a/hololinked/webdashboard/components/mui/__init__.py b/hololinked/webdashboard/components/mui/__init__.py deleted file mode 100644 index 07c0739..0000000 --- a/hololinked/webdashboard/components/mui/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .Button import hrefButton, ButtonBase, Button -from .Divider import Divider -from .ButtonGroup import ButtonGroup -# from .Icon import Icon -# from .IconButton import IconButton -# from .MaterialIcon import MaterialIcon -from .TextField import TextField -from .Switch import Switch -# from .Slider import Slider -from .Layout import Stack, Box -from .RadioGroup import RadioGroup, FormGroup -from .Radio import Radio -from .FormControlLabel import FormControlLabel \ No newline at end of file diff --git a/hololinked/webdashboard/components/plotly.py b/hololinked/webdashboard/components/plotly.py deleted file mode 100644 index 9b18d16..0000000 --- a/hololinked/webdashboard/components/plotly.py +++ /dev/null @@ -1,13 +0,0 @@ -from ...param.parameters import String -from ..basecomponents import RGLBaseComponent -from ..baseprops import ObjectProp, ComponentName -from ..axios import AxiosRequestConfig -from ..valuestub import ValueStub - -class PlotlyFigure(RGLBaseComponent): - plot = String ( doc = """Enter here the plot configuration as entered in plotly python. Tip : create a python - plotly figure, extract JSON and assign it to this prop for verification. This prop is not verified - except that it is valid JSON specification. All errors appear at frontend.""") - - sources = ObjectProp ( item_type = (AxiosRequestConfig, ValueStub) ) - componentName = ComponentName ( "ContextfulPlotlyGraph" ) diff --git a/hololinked/webdashboard/constants.py b/hololinked/webdashboard/constants.py deleted file mode 100644 index 0a61d6c..0000000 --- a/hololinked/webdashboard/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -allowedRequestMethods = ['get', 'post', 'put', 'delete', 'options'] -INVALID_PROP = "INVALID_PROP" -allowedEffectiveValueTypes = [None, bool, int, float, str, 'htmlid'] -url_regex : str = r'[\-a-zA-Z0-9@:%._\/\+~#=]{1,256}' \ No newline at end of file diff --git a/hololinked/webdashboard/exceptions.py b/hololinked/webdashboard/exceptions.py deleted file mode 100644 index 911e779..0000000 --- a/hololinked/webdashboard/exceptions.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import Any - - -def getStringRepr(object : Any, NumOfChars : int = 200): - if isinstance(object, str): - if (len(object) > NumOfChars): - return object[0:int(NumOfChars/2)]+ "..." + object[-int(NumOfChars/2):-1] - return object - elif isinstance(object, (float, int, bool)): - return object - elif hasattr(object, '__iter__'): - items = [] - limiter = ']' - length = 0 - for item in object: - string = str(item) - length += len(string) - if length < 200: - items.append(string) - else: - limiter = ', ...]' - break - items = '[' + ', '.join(items) + limiter - return items - else: - return object - - -def raise_PropError(Exc : Exception, pobj, prop : str) -> None: - # We need to reduce the stack - if hasattr(pobj, 'id') and pobj.id is not None: - if hasattr(pobj, 'componentName'): - message = "{}.{} with id '{}' - {}".format(pobj.componentName, prop, pobj.id, str(Exc)) - elif hasattr(pobj, 'actionType'): - message = "{}.{} with id '{}' - {}".format(pobj.actionType, prop, pobj.id, str(Exc)) - else: - message = "{} of object with id '{}' - {}".format(prop, pobj.id, str(Exc)) - else: - message = "{} - {}.".format(prop, str(Exc)) - # does not work for axios - raise type(Exc)(message) - - -__all__ = ['raise_PropError', 'getStringRepr'] \ No newline at end of file diff --git a/hololinked/webdashboard/serializer.py b/hololinked/webdashboard/serializer.py deleted file mode 100644 index 43eaa89..0000000 --- a/hololinked/webdashboard/serializer.py +++ /dev/null @@ -1,115 +0,0 @@ -import typing -from inspect import getmro - -from ..param import Parameter -from ..param.serializer import Serialization, serializers - -from .valuestub import ValueStub, ActionStub -from .basecomponents import ReactBaseComponent -from .actions import BaseAction -from .utils import unique_id - - - -class FrontendJSONSpecSerializer(Serialization): - """ - Produce a dict of the parameters. - This is not strictly a serializer as it returns a dict and not a string. - Serialization of the return value should be done at the level of calling funcion - allowing manipulation before string serialization. - """ - - excludeKeys = [ 'name' ] - topLevelKeys = [ - # General HTML related keys - 'id', - # custom keys - 'tree', 'dependents', 'dependentsExist', 'componentName', 'outputID', - # MUI specific keys - 'component', 'styles', 'classes', - # React grid layout keys - # state machine keys - # plotly - 'sources', 'plot', - ] - metadataKeys = [ 'RGLDataGrid', 'styling' ] - # ignorable keys are ignored when they are None - ignorableKeys = [ 'dependents', 'dependentsExist', 'stateMachine', 'children', 'styling', 'dataGrid'] - - propFilter = excludeKeys + topLevelKeys + metadataKeys # Keys which are not in this will generally be props - - @classmethod - def serialize_parameters(cls, pobj : 'ReactBaseComponent', - subset : typing.Union[typing.List, typing.Tuple, None] = None)-> typing.Dict[str, typing.Any]: - JSONDict = dict(props={}) - pobjtype = type(pobj) - for key, param in pobj.parameters.descriptors.items(): - if subset is not None and key not in subset: - pass - elif key not in cls.excludeKeys: - value = param.__get__(pobj, pobjtype) - if not isinstance(value, ValueStub): - value = param.serialize(value) - cls._assign_dict_heirarchically(pobj, key, value, JSONDict) - return JSONDict - - @classmethod - def _assign_dict_heirarchically(cls, pobj : 'ReactBaseComponent', key : str, value : typing.Any, - JSONDict : typing.Dict[str, typing.Any]) -> None: - if key in cls.ignorableKeys and value is None: - return - if isinstance(value, BaseAction): - if value.id is None: - # Just a shield so as to not commit errors in coding. For the user the code should never reach here. - raise ValueError("no ID has been assigned to action. contact developer.") - try: - JSONDict['actions'][value.id] = value - except KeyError: - JSONDict['actions'] = {} - JSONDict['actions'][value.id] = value - value = ActionStub(value.id) - # & continue further down the logic to assign the stub to the appropriate field - if key in cls.topLevelKeys: - JSONDict[key] = value - elif key in cls.metadataKeys: - try: - JSONDict['metadata'][key] = value - except KeyError: - JSONDict['metadata'] = {} - JSONDict['metadata'][key] = value - elif key == 'stateMachine': - for state, props in value.states.items(): - for kee, val in props.items(): - if isinstance(val, BaseAction): - if val.id is None: - val.id = unique_id(prefix = 'actionid_') # type: ignore - try: - JSONDict['actions'][val.id] = val - except KeyError: - JSONDict['actions'] = {} - JSONDict['actions'][val.id] = val - props[kee] = ActionStub(val.id) - JSONDict[key] = value - elif key == 'children': - if value is not None: - if isinstance(value, list) and len(value) > 0: - JSONDict[key] = [child.id if isinstance(child, ReactBaseComponent) else child for child in value] - else: - JSONDict[key] = [value] - elif key == 'value': - return - elif isinstance(value, ReactBaseComponent): - JSONDict['props'][key] = value.id - if JSONDict.get('nodes', None) is None: - JSONDict['nodes'] = {} - JSONDict['nodes'] - value.json(JSONDict) - elif key not in cls.propFilter: - JSONDict['props'][key] = value - else: - raise NotImplementedError("No implementation of key {} for serialization.".format(key)) - -serializers['FrontendJSONSpec'] = FrontendJSONSpecSerializer # type: ignore - - -__all__ = ['FrontendJSONSpecSerializer'] \ No newline at end of file diff --git a/hololinked/webdashboard/statemachine.py b/hololinked/webdashboard/statemachine.py deleted file mode 100644 index b399d22..0000000 --- a/hololinked/webdashboard/statemachine.py +++ /dev/null @@ -1,137 +0,0 @@ -import typing - -from ..param import Parameterized -from ..param.parameters import ClassSelector, TypedDict, Parameter, Boolean, String -from ..param.exceptions import wrap_error_text - -from .baseprops import StringProp -from .axios import AxiosRequestConfig -from .exceptions import raise_PropError - - - -class RemoteFSM: - - machineType = String ( default = "RemoteFSM", readonly = True) - defaultState = String ( default = None, allow_None = True ) - subscription = ClassSelector ( default = '', class_ = (AxiosRequestConfig, str)) - states = TypedDict ( default = None, key_type = str, item_type = dict, allow_None = True ) - - def __init__(self, subscription : str, defaultState : str, **states : typing.Dict[str, typing.Any]) -> None: - self.subscription = subscription - self.defaultState = defaultState - if len(states) > 0: - self.states = states - else: - raise ValueError(wrap_error_text("""state machine not complete, please specify key-value pairs - of state name vs. props for a valid state machine""")) - - def json(self): - return { - 'type' : self.machineType, - 'defaultState' : self.defaultState, - 'subscription' : self.subscription, - 'states' : self.states - } - - - -class SimpleFSM: - - machineType = String ( default = "SimpleFSM", readonly = True) - defaultState = String ( default = None, allow_None = True ) - states = TypedDict ( default = None, key_type = str, item_type = dict, allow_None = True ) - - def __init__(self, defaultState, **states : typing.Dict[str, typing.Any]) -> None: - self.defaultState = defaultState - self.states = states - - def json(self): - return { - 'type' : self.machineType, - 'defaultState' : self.defaultState, - 'states' : self.states - } - - -stateMachineKW = frozenset(['onEntry', 'onExit', 'target', 'when']) - -class StateMachineProp(Parameter): - - def __init__(self, default: typing.Any = None, allow_None: bool = False, **kwargs): - kwargs['doc'] = """apply state machine to a component. - Different props can be applied in different states and only the - state needs to be set to automatically apply the props""" - super().__init__(default, constant = True, allow_None = allow_None, **kwargs) - - # @instance_descriptor - all descriptors apply only to instance __dict__ - def __set__(self, obj : Parameterized, value : typing.Any) -> None: - """ - Set the value for this Parameter. - - If called for a Parameterized class, set that class's - value (i.e. set this Parameter object's 'default' attribute). - - If called for a Parameterized instance, set the value of - this Parameter on that instance (i.e. in the instance's - __dict__, under the parameter's internal_name). - - If the Parameter's constant attribute is True, only allows - the value to be set for a Parameterized class or on - uninitialized Parameterized instances. - - If the Parameter's readonly attribute is True, only allows the - value to be specified in the Parameter declaration inside the - Parameterized source code. A read-only parameter also - cannot be set on a Parameterized class. - - Note that until we support some form of read-only - object, it is still possible to change the attributes of the - object stored in a constant or read-only Parameter (e.g. one - item in a list). - """ - if isinstance(value, (RemoteFSM, SimpleFSM)): - obj_params = obj.parameters.descriptors - if value.states is not None: - for key, props_dict in value.states.items(): - to_update = {} - if not len(props_dict) > 0: - raise_PropError(ValueError("state machine props dictionary empty, please enter few props".format(key)), - self.owner, "stateMachine") - for prop_name, prop_value in props_dict.items(): - if prop_name in stateMachineKW: - continue - if obj_params.get(prop_name, None) is None: - raise_PropError(ValueError("prop name {} is not a valid prop of {}".format(prop_name, self.__class__.__name__)), - self.owner, "stateMachine") - to_update[prop_name] = obj_params[prop_name].validate_and_adapt(prop_value) - props_dict.update(to_update) # validators also adapt value so we need to reset it, - # may be there is a more efficient way to do this - elif not(value is None and self.allow_None): - raise_PropError(TypeError("stateMachine prop is of invalid type, expected type : StateMachine, given Type : {}".format( - type(value))), self.owner, "stateMachine") - - obj.__dict__[self._internal_name] = value - - - -# key = String() -# initial = String() -# type = ClassSelector(objects = ['atomic', 'compound', 'parallel', 'final', 'history']) -# states = ClassSelector() -# invoke = ClassSelector() -# on = ClassSelector() -# entry = ClassSelector() -# exit() -# after -# always -# parent -# struct -# meta - - - - - - -__all__ = ['RemoteFSM', 'SimpleFSM'] \ No newline at end of file diff --git a/hololinked/webdashboard/utils.py b/hololinked/webdashboard/utils.py deleted file mode 100644 index 4134b15..0000000 --- a/hololinked/webdashboard/utils.py +++ /dev/null @@ -1,13 +0,0 @@ -from ..param.parameters import String -import uuid - - -def unique_id(prefix : str = 'htmlid_') -> str: - return "{}{}".format(prefix if isinstance(prefix, str) else '', str(uuid.uuid4())) - - -class __UniqueValueContainer__: - FooBar = String( default="ReadOnly", readonly = True, constant = True, - doc = "This container might be useful in internal validations, please dont modify it outside the package.") - -U = __UniqueValueContainer__() \ No newline at end of file diff --git a/hololinked/webdashboard/valuestub.py b/hololinked/webdashboard/valuestub.py deleted file mode 100644 index 617abf8..0000000 --- a/hololinked/webdashboard/valuestub.py +++ /dev/null @@ -1,452 +0,0 @@ -from dataclasses import dataclass, asdict, field -from typing import Any, Union, Dict, List -from collections.abc import MutableMapping - - -addop = "+" -subop = "-" -mulop = "*" -divop = "/" -floordivop = "//" -modop = "%" -powop = "^" -gtop = ">" -geop = ">=" -ltop = "<" -leop = "<=" -eqop = "==" -neop = "!=" - -orop = "or" -andop = "and" -xorop = "xor" -notop = "not" - - -RAW = "raw" -NESTED_ID = "nested_id" -ACTION = "action" -ACTION_RESULT = "result_of_action" -# The above are the interpretations of an operand -# RAW means op1 or op2 is JSON compatible value -# NESTED means op1 or op2 itself is yet another stub -# ACTION means the stub should be evaluated to become a method in the frontend -# ACTION RESULT means the op1 or op2 is retrived as a JSON compatible value after an action is performed - - -@dataclass -class abstract_stub: - - def json(self): - return asdict(self) - -@dataclass -class single_op_stub(abstract_stub): - op1 : Any - op1interpretation : str - op1dtype : str = field(default = "unknown") - -@dataclass -class two_ops_stub(abstract_stub): - op1 : Any - op1interpretation : str - op2 : Any - op2interpretation : Any - op : str - op1dtype : str = field(default = "unknown") - op2dtype : str = field(default = "unknown") - -@dataclass -class dotprop_stub(abstract_stub): - op1 : Any - op1interpretation : str - op1prop : str - op1dtype : str = field(default = "unknown") - -@dataclass -class funcop_stub(abstract_stub): - op1 : Any - op1func : str - op1args : Any - op1interpretation : str - op1dtype : str = field(default = "unknown") - -@dataclass -class json_stub(abstract_stub): - op1 : str - op1interpretation : str - fields : Union[str, None] - op1dtype : str = field(default = "unknown") - - -@dataclass -class action_stub(abstract_stub): - op1 : str - op1interpretation : str - op1dtype : str = field(default = "unknown") - - -stub_type = Union[two_ops_stub, single_op_stub, funcop_stub, dotprop_stub, json_stub] - - -class ValueStub: - """ - A container class to store information about operations to be performed directly at the frontend without making - a request to the backend. The goal is to generate a JSON of the following form to be used at the frontend - - { - 'op1' - first operand, - 'op1type' - the javasript type of the value stored by the first operand. For ex - a textfield component stores string - 'op1interpretaion' - interpretation of first operand, four are possible now - RAW, NESTED, ACTION & ACTION_RESULT - 'op2' - second operand, - 'op2type' - the type of the value stored by the second operand. - 'op2interpretation' - interpretation of second operand - 'op' - the operation required to be performed - }. - - This JSON is also composed inside this class as an instance of ValueStub - - Attributes: - op1 - the id of the ReactBaseComponent which composes the ValueContainer, consequently it is the first operand 'op1' - dtype - the javascript data type that the container is storing after instantiation - value - object which stores the final information on how to compute the value - - A few varieties are possible : - 1) if stored as - { - 'op1' : some valid HTML ID - 'op1type' : some valid data type - } - it means there is no operation, and the value stored by the HTML component is the value of the component. - - 2) if stored as - { - 'op1' : some valid HTML ID - 'op1type' : some valid data type - 'op2' : another valid HTML ID - 'op2type' : some valid data type - 'op' : some valid operation - } - this means there is some operation, and the final value is arrived by operating on the operands - - 3) Similar to above, it is possible to store function instructions - - More importantly, some HTML components are input components (for ex - textfield) and yet others are output - components (for ex - typography). For input components, the first JSON is stored and for output component the second - JSON or third type is stored so that some value can be computed and shown as display. - - One can also assign these JSON to individual props when suitable, - """ - - dtype = 'unknown' - acceptable_pydtypes = None - - def __init__(self, action_result_id_or_stub : Union[str, stub_type, None] = None) -> None: - self._uninitialized = True - if action_result_id_or_stub is not None: - self.create_base_info(action_result_id_or_stub) - - def json(self, return_as_dict : bool = False): - return self._info - - def create_base_info(self, value: Any) -> None: - if isinstance(value, str): - self._info = single_op_stub(op1 = value, op1dtype = self.dtype, op1interpretation = ACTION_RESULT) - elif isinstance(value, two_ops_stub): - self._info = value - self._info.op1dtype = self.dtype - self._info.op2dtype = self.dtype - elif isinstance(value, abstract_stub): - self._info = value - self._info.op1dtype = self.dtype # type: ignore - # we assume abstract stub is never used, all other stubs have op1 - else: - raise ValueError("ValueStub assignment failed. Given type : {}, expected any of stub types.".format(type(value))) - self._uninitialized = False - - def compute_dependents(self, sub_info : Union[single_op_stub, two_ops_stub, None] = None): - raise NotImplementedError("compute_dependents() not supported for {}".format(self.__class__)) - - dependents = property(compute_dependents) - - def create_op_info(self, op2 : "ValueStub", op : str): - raise NotImplementedError("create_func_info() not supported for {}".format(self.__class__)) - - def create_func_info(self, func_name : str, args: Any): - raise NotImplementedError("create_func_info() not supported for {}".format(self.__class__)) - - def create_dot_property_info(self, prop_name : str): - raise NotImplementedError("create_dot_property_info() not supported for {}".format(self.__class__)) - - -class SupportsNumericalOperations(ValueStub): - - acceptable_pydtypes = () - # refer NumberStub for meaning of acceptable_pydtypes - - def create_op_info(self, op2 : Union["ValueStub", float, int], op : str) -> two_ops_stub: - """ - we already have op1, op2 requires to be set correctly for completing the op info. - Generally we need to set op2 & op2intepretation, dtype will set at init while creating a new stub - """ - if self._uninitialized: - raise AttributeError("HTML id requires to be set before stub information (props that allow to access to directly manipulate at the frontend).") - if isinstance(op2, self.acceptable_pydtypes): - op2interpration = RAW - # note : op2 = op2 - elif isinstance(op2, self.__class__): - if op2._uninitialized: - raise AttributeError("HTML id requires to be set before stub information (props that allow to access to directly manipulate at the frontend).") - if isinstance(op2._info, single_op_stub): - # Order of assignment is important for below - op2interpration = op2._info.op1interpretation - op2 = op2._info.op1 - else: - op2interpration = NESTED_ID - op2 = op2._info # type: ignore - else: - raise TypeError("cannot perform {} operation on {} and {} in ValueStub".format(op, self._info.op1, op2)) - - if isinstance(self._info, single_op_stub): - return two_ops_stub( - op1 = self._info.op1, - op1interpretation = self._info.op1interpretation, - op2 = op2, - op2interpretation = op2interpration, - op = op - ) - else: - return two_ops_stub( - op1 = self._info, - op1interpretation = NESTED_ID, - op2 = op2, - op2interpretation = op2interpration, - op = op - ) - - -class HasDotProperty(ValueStub): - - def create_dot_property_info(self, prop_name : str) -> dotprop_stub: - if isinstance(self._info, single_op_stub): - return dotprop_stub( - op1 = self._info.op1, - op1interpretation = self._info.op1interpretation, - op1prop = prop_name - ) - elif isinstance(self._info, abstract_stub): - return dotprop_stub( - op1 = self._info, - op1interpretation = NESTED_ID, - op1prop = prop_name, - ) - raise NotImplementedError("Internal error regarding stub information calculation. {} has {} stub which is unexpected.".format( - self.__class__, type(self._info))) - - -class SupportsMethods(ValueStub): - - def create_func_info(self, func_name : str, args : Any): - if isinstance(self._info, single_op_stub): - return funcop_stub( - op1 = self._info.op1, - op1interpretation = self._info.op1interpretation, - op1args = args, - op1func = func_name - ) - elif isinstance(self._info, abstract_stub): - return funcop_stub( - op1 = self._info, - op1interpretation = NESTED_ID, - op1args = args, - op1func = func_name - ) - raise NotImplementedError("Internal error regarding stub information calculation. {} has {} stub which is unexpected".format( - self.__class__, type(self._info))) - - - - -class NumberStub(SupportsNumericalOperations): - - dtype = "number" - acceptable_pydtypes = (float, int) - - def __add__(self, value : ValueStub) -> "NumberStub": - return NumberStub(self.create_op_info(value, addop)) - - def __sub__(self, value : ValueStub) -> "NumberStub": - return NumberStub(self.create_op_info(value, subop)) - - def __mul__(self, value : ValueStub) -> "NumberStub": - return NumberStub(self.create_op_info(value, mulop)) - - def __floordiv__(self, value : ValueStub) -> "NumberStub": - return NumberStub(self.create_op_info(value, floordivop)) - - def __truediv__(self, value : ValueStub) -> "NumberStub": - return NumberStub(self.create_op_info(value, divop)) - - def __mod__(self, value : ValueStub) -> "NumberStub": - return NumberStub(self.create_op_info(value, modop)) - - def __pow__(self, value : ValueStub) -> "NumberStub": - return NumberStub(self.create_op_info(value, powop)) - - def __gt__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, gtop)) - - def __ge__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, geop)) - - def __lt__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, ltop)) - - def __le__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, leop)) - - def __eq__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, eqop)) - - def __ne__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, neop)) - - - -class BooleanStub(SupportsNumericalOperations): - - dtype = "boolean" - acceptable_pydtypes = bool - - def __or__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, orop)) - - def __and__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, andop)) - - def __xor__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, xorop)) - - def __not__(self, value : ValueStub) -> "BooleanStub": - return BooleanStub(self.create_op_info(value, notop)) - - - -class StringStub(SupportsNumericalOperations, HasDotProperty, SupportsMethods): - - dtype = "string" - acceptable_pydtypes = str - - def length(self) -> NumberStub: - return NumberStub(self.create_dot_property_info("length")) - - def charAt(self): - raise NotImplementedError("charAt is not yet implemented") - - def capitalize(self): - raise NotImplementedError("capitalize is not yet implemented") - - def slice(self, start : int, end : Union[int, None] = None) -> "StringStub": - if isinstance(start, int) and (isinstance(end, int) or end is None): - return StringStub(self.create_func_info("slice", [start, end])) - else: - raise TypeError("start/end index not specified as integer : start - {}, end - {}".format(type(start), type(end))) - - def substring(self, start : int, end : int) -> "StringStub": - if isinstance(start, int) and isinstance(end, int): - return StringStub(self.create_func_info("substring", [start, end])) - else: - raise TypeError("start/end index not specified as integer : start - {}, end - {}".format(type(start), type(end))) - - def substr(self, start : int, length : Union[int, None] = None) -> "StringStub": - if isinstance(start, int) and (isinstance(length, int) or length is None): - return StringStub(self.create_func_info("substr", [start, length])) - else: - raise TypeError("start/length value not specified as integer : start - {}, end - {}".format(type(start), type(length))) - - def __add__(self, value : ValueStub): - return StringStub(self.create_op_info(value, addop)) - - -class JSON(SupportsMethods): - - dtype = 'object' - acceptable_pydtypes = (dict, MutableMapping) - - def create_base_info(self, value: Any) -> None: - if isinstance(value , str): - self._info = json_stub(op1 = value, fields = None, op1interpretation = ACTION_RESULT, op1dtype = self.dtype) # type: ignore - elif isinstance(value, (json_stub, funcop_stub)): - self._info = value - self._info.op1dtype = self.dtype - else: - raise ValueError("ValueStub assignment failed. Given type : {}, expected ".format(type(value))) - - @classmethod - def stringify(cls, value : Any, space = None): - if isinstance(value, (ObjectStub, JSON)): - _info = funcop_stub( - op1 = value._info, - op1func = 'stringify', - op1args = [None, space], - op1interpretation = value._info.op1interpretation # type: ignore - ) - elif isinstance(value, cls.acceptable_pydtypes): - _info = funcop_stub( - op1 = value, - op1func = 'stringify', - op1args = [None, space], - op1interpretation = RAW - ) - else: - raise ValueError(f"Given value cannot be stringified. Only JSON or Dict is accepted, given type {type(value)}") - return StringStub(_info) - - -class ObjectStub(ValueStub): - - # Differs from JSON stub in the sense that JSON stub supports the javascript methods - # like stringify - - dtype = "object" - - def json(self): - return self._info if hasattr(self, '_info') else None - - def create_base_info(self, value : Any): - if isinstance(value, str): - self._info = json_stub(op1 = value, fields = None, op1interpretation = ACTION_RESULT, op1dtype = self.dtype) - elif isinstance(value, json_stub): - self._info = value - self._info.op1dtype = self.dtype - else: - raise ValueError("ValueStub assignment failed. Given type : {}, expected ".format(type(value))) - - def __getitem__(self, field : str): - assert isinstance(field , str), "indexing a response object should be JSON compliant, give string field names. Given type {}".format(type(field)) - if not hasattr(self, '_info'): - raise AttributeError("information container not yet created for ObjectStub object") - if self._info.fields is None: - fields = field - else: - fields = self._info.fields + "." + field - return ObjectStub(json_stub(op1 = self._info.op1, fields = fields, op1interpretation = ACTION_RESULT)) - - -class ActionStub(ValueStub): - - dtype = 'action' - - def create_base_info(self, value: Any) -> None: - if isinstance(value, str): - self._info = action_stub(op1 = value, op1interpretation = ACTION, op1dtype = self.dtype) - else: - raise ValueError("ValueStub assignment failed. Given type : {}, expected HTML-like id string.".format(type(value))) - - - -def string_cast(value): - return StringStub( - single_op_stub( - op1 = value, - op1interpretation = RAW - )) \ No newline at end of file diff --git a/hololinked/webdashboard/visualization_parameters.py b/hololinked/webdashboard/visualization_parameters.py deleted file mode 100644 index 53f76d4..0000000 --- a/hololinked/webdashboard/visualization_parameters.py +++ /dev/null @@ -1,158 +0,0 @@ -import os -import typing -from enum import Enum -from ..param.parameterized import Parameterized -from ..server.constants import USE_OBJECT_NAME, HTTP_METHODS -from ..server.remote_parameter import RemoteParameter -from ..server.events import Event - -try: - import plotly.graph_objects as go -except: - go = None - -class VisualizationParameter(RemoteParameter): - # type shield from RemoteParameter - pass - - - -class PlotlyFigure(VisualizationParameter): - - __slots__ = ['data_sources', 'update_event_name', 'refresh_interval', 'polled', - '_action_stub'] - - def __init__(self, default_figure, *, - data_sources : typing.Dict[str, typing.Union[RemoteParameter, typing.Any]], - polled : bool = False, refresh_interval : typing.Optional[int] = None, - update_event_name : typing.Optional[str] = None, doc: typing.Union[str, None] = None, - URL_path : str = USE_OBJECT_NAME) -> None: - super().__init__(default=default_figure, doc=doc, constant=True, readonly=True, URL_path=URL_path) - self.data_sources = data_sources - self.refresh_interval = refresh_interval - self.update_event_name = update_event_name - self.polled = polled - - def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> None: - if slot == 'owner' and self.owner is not None: - from ..webdashboard import RepeatedRequests, AxiosRequestConfig, EventSource - if self.polled: - if self.refresh_interval is None: - raise ValueError(f'for PlotlyFigure {self.name}, set refresh interval (ms) since its polled') - request = AxiosRequestConfig( - url=f'/parameters?{"&".join(f"{key}={value}" for key, value in self.data_sources.items())}', - # Here is where graphQL is very useful - method='get' - ) - self._action_stub = RepeatedRequests( - requests=request, - interval=self.refresh_interval, - ) - elif self.update_event_name: - if not isinstance(self.update_event_name, str): - raise ValueError(f'update_event_name for PlotlyFigure {self.name} must be a string') - request = EventSource(f'/event/{self.update_event_name}') - self._action_stub = request - else: - pass - - for field, source in self.data_sources.items(): - if isinstance(source, RemoteParameter): - if isinstance(source, EventSource): - raise RuntimeError("Parameter field not supported for event source, give str") - self.data_sources[field] = request.response[source.name] - elif isinstance(source, str): - if isinstance(source, RepeatedRequests) and source not in self.owner.parameters: # should be in remote parameters, not just parameter - raise ValueError(f'data_sources must be a string or RemoteParameter, type {type(source)} has been found') - self.data_sources[field] = request.response[source] - else: - raise ValueError(f'given source {source} invalid. Specify str for events or Parameter') - - return super()._post_slot_set(slot, old, value) - - def validate_and_adapt(self, value : typing.Any) -> typing.Any: - if self.allow_None and value is None: - return - if not go: - raise ImportError("plotly was not found/imported, install plotly to suport PlotlyFigure paramater") - if not isinstance(value, go.Figure): - raise TypeError(f"figure arguments accepts only plotly.graph_objects.Figure, not type {type(value)}", - self) - return value - - @classmethod - def serialize(cls, value): - return value.to_json() - - - -class Image(VisualizationParameter): - - __slots__ = ['event', 'streamable', '_action_stub', 'data_sources'] - - def __init__(self, default : typing.Any = None, *, streamable : bool = True, doc : typing.Optional[str] = None, - constant : bool = False, readonly : bool = False, allow_None : bool = False, - URL_path : str = USE_OBJECT_NAME, - http_method : typing.Tuple[typing.Optional[str], typing.Optional[str]] = (HTTP_METHODS.GET, HTTP_METHODS.PUT), - state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, - db_persist : bool = False, db_init : bool = False, db_commit : bool = False, - class_member : bool = False, fget : typing.Optional[typing.Callable] = None, - fset : typing.Optional[typing.Callable] = None, fdel : typing.Optional[typing.Callable] = None, - deepcopy_default : bool = False, per_instance_descriptor : bool = False, - precedence : typing.Optional[float] = None) -> None: - super().__init__(default, doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, - URL_path=URL_path, http_method=http_method, state=state, - db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, - fget=fget, fset=fset, fdel=fdel, deepcopy_default=deepcopy_default, - per_instance_descriptor=per_instance_descriptor, precedence=precedence) - self.streamable = streamable - - def __set_name__(self, owner : typing.Any, attrib_name : str) -> None: - super().__set_name__(owner, attrib_name) - self.event = Event(attrib_name) - - def _post_value_set(self, obj : Parameterized, value : typing.Any) -> None: - super()._post_value_set(obj, value) - if value is not None: - print(f"pushing event {value[0:100]}") - self.event.push(value, serialize=False) - - def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> None: - if slot == 'owner' and self.owner is not None: - from ..webdashboard import SSEVideoSource - request = SSEVideoSource(f'/event/image') - self._action_stub = request - self.data_sources = request.response - return super()._post_slot_set(slot, old, value) - - - - -class FileServer(RemoteParameter): - - __slots__ = ['directory'] - - def __init__(self, directory : str, *, doc : typing.Optional[str] = None, URL_path : str = USE_OBJECT_NAME, - class_member: bool = False, per_instance_descriptor: bool = False) -> None: - self.directory = self.validate_and_adapt_directory(directory) - super().__init__(default=self.load_files(self.directory), doc=doc, URL_path=URL_path, constant=True, - class_member=class_member, per_instance_descriptor=per_instance_descriptor) - - def validate_and_adapt_directory(self, value : str): - if not isinstance(value, str): - raise TypeError(f"FileServer parameter not a string, but type {type(value)}", self) - if not os.path.isdir(value): - raise ValueError(f"FileServer parameter directory '{value}' not a valid directory", self) - if not value.endswith('\\'): - value += '\\' - return value - - def load_files(self, directory : str): - return [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))] - -class DocumentationFolder(FileServer): - - def __init__(self, directory : str, *, doc : typing.Optional[str] = None, URL_path : str = '/documentation', - class_member: bool = False, per_instance_descriptor: bool = False) -> None: - super().__init__(directory=directory, doc=doc, URL_path=URL_path, - class_member=class_member, per_instance_descriptor=per_instance_descriptor) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2c03b5e..69595f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -argon2==0.1.10 +argon2-cffi==23.1.0 ifaddr==0.2.0 msgspec==0.18.6 pyzmq==25.1.0 SQLAlchemy==2.0.21 SQLAlchemy_Utils==0.41.1 tornado==6.3.3 - +jsonschema==4.22.0 diff --git a/setup.py b/setup.py index fff0b2d..59172c8 100644 --- a/setup.py +++ b/setup.py @@ -2,14 +2,13 @@ # read the contents of your README file from pathlib import Path -this_directory = Path(__file__).parent -long_description = (this_directory / "README.md").read_text() +long_description = (Path(__file__).parent/"README.md").read_text() setuptools.setup( name="hololinked", - version="0.1.2", - author="Vignesh Vaidyanathan", + version="0.2.1", + author="Vigneh Vaidyanathan", author_email="vignesh.vaidyanathan@hololinked.dev", description="A ZMQ-based Object Oriented RPC tool-kit with HTTP support for instrument control/data acquisition or controlling generic python objects.", long_description=long_description, @@ -41,13 +40,14 @@ ], python_requires='>=3.7', install_requires=[ - "argon2-cffi>=0.1.10", + "argon2-cffi>=23.0.0", "ifaddr>=0.2.0", "msgspec>=0.18.6", "pyzmq>=25.1.0", "SQLAlchemy>=2.0.21", "SQLAlchemy_Utils>=0.41.1", - "tornado>=6.3.3" + "tornado>=6.3.3", + "jsonschema>=4.22.0" ], license="BSD-3-Clause", license_files=('license.txt', 'licenses/param-LICENSE.txt', 'licenses/pyro-LICENSE.txt'), diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/buggy/test_http_server.py b/tests/buggy/test_http_server.py new file mode 100644 index 0000000..84e9513 --- /dev/null +++ b/tests/buggy/test_http_server.py @@ -0,0 +1,64 @@ +import queue, requests, unittest, logging, time, multiprocessing + +try: + from utils import TestCase, TestRunner, print_lingering_threads + from things import start_thing_forked, TestThing +except ImportError: + from .utils import TestCase, TestRunner, print_lingering_threads + from .things import start_thing_forked, TestThing + + + +class TestHTTPServer(TestCase): + + @classmethod + def setUpClass(self): + # Code to set up any necessary resources or configurations before each test case + print("test HTTP server") + + @classmethod + def tearDownClass(self): + # Code to clean up any resources or configurations after each test case + print("tear down test HTTP server") + + + def test_1_threaded_http_server(self): + # Connect HTTP server and Thing in different threads + done_queue = queue.Queue() + T = start_thing_forked( + TestThing, + instance_name='test-http-server-in-thread', + as_process=False, + http_server=True, + done_queue=done_queue + ) + time.sleep(1) # let the server start + session = requests.Session() + session.post('http://localhost:8080/test-http-server-in-thread/exit') + session.post('http://localhost:8080/stop') + T.join() + self.assertEqual(done_queue.get(), 'test-http-server-in-thread') + done_queue.task_done() + done_queue.join() + + + def test_2_process_http_server(self): + # Connect HTTP server and Thing in separate processes + done_queue = multiprocessing.Queue() + P = start_thing_forked( + TestThing, + instance_name='test-http-server-in-process', + as_process=True, + http_server=True, + done_queue=done_queue + ) + time.sleep(5) # let the server start + session = requests.Session() + session.post('http://localhost:8080/test-http-server-in-process/exit') + session.post('http://localhost:8080/stop') + self.assertEqual(done_queue.get(), 'test-http-server-in-process') + + +if __name__ == '__main__': + unittest.main(testRunner=TestRunner()) + \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..7992173 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,10 @@ +argon2-cffi==23.1.0 +ifaddr==0.2.0 +msgspec==0.18.6 +pyzmq==25.1.0 +SQLAlchemy==2.0.21 +SQLAlchemy_Utils==0.41.1 +tornado==6.3.3 +jsonschema==4.22.0 +requests==2.32.3 +numpy==2.0.0 diff --git a/tests/test_action.py b/tests/test_action.py new file mode 100644 index 0000000..0eb300c --- /dev/null +++ b/tests/test_action.py @@ -0,0 +1,262 @@ +import asyncio +import typing +import unittest +import logging +import multiprocessing +from hololinked.server.dataklasses import ActionInfoValidator +from hololinked.server.thing import Thing, action +from hololinked.server.utils import isclassmethod +from hololinked.param import ParameterizedFunction +from hololinked.client import ObjectProxy +from hololinked.server.properties import Number, String, ClassSelector +try: + from .utils import TestCase, TestRunner + from .things import TestThing +except ImportError: + from utils import TestCase, TestRunner + from things import start_thing_forked + + + +class TestThing(Thing): + + def action_echo(self, value): + return value + + @classmethod + def action_echo_with_classmethod(self, value): + return value + + async def action_echo_async(self, value): + await asyncio.sleep(0.1) + return value + + @classmethod + async def action_echo_async_with_classmethod(self, value): + await asyncio.sleep(0.1) + return value + + class typed_action(ParameterizedFunction): + + arg1 = Number(bounds=(0, 10), step=0.5, default=5, crop_to_bounds=True, + doc='arg1 description') + arg2 = String(default='hello', doc='arg2 description', regex='[a-z]+') + arg3 = ClassSelector(class_=(int, float, str), + default=5, doc='arg3 description') + + def __call__(self, instance, arg1, arg2, arg3): + return instance.instance_name, arg1, arg2, arg3 + + + class typed_action_without_call(ParameterizedFunction): + + arg1 = Number(bounds=(0, 10), step=0.5, default=5, crop_to_bounds=True, + doc='arg1 description') + arg2 = String(default='hello', doc='arg2 description', regex='[a-z]+') + arg3 = ClassSelector(class_=(int, float, str), + default=5, doc='arg3 description') + + + class typed_action_async(ParameterizedFunction): + + arg1 = Number(bounds=(0, 10), step=0.5, default=5, crop_to_bounds=True, + doc='arg1 description') + arg2 = String(default='hello', doc='arg2 description', regex='[a-z]+') + arg3 = ClassSelector(class_=(int, float, str), + default=5, doc='arg3 description') + + async def __call__(self, instance, arg1, arg2, arg3): + await asyncio.sleep(0.1) + return instance.instance_name, arg1, arg2, arg3 + + + def __internal__(self, value): + return value + + def incorrectly_decorated_method(self, value): + return value + + def not_an_action(self, value): + return value + + async def not_an_async_action(self, value): + await asyncio.sleep(0.1) + return value + + + +class TestAction(TestCase): + + @classmethod + def setUpClass(self): + print("test action") + self.thing_cls = TestThing + + @classmethod + def tearDownClass(self) -> None: + print("tear down test action") + + def test_1_allowed_actions(self): + # instance method can be decorated with action + self.assertEqual(self.thing_cls.action_echo, action()(self.thing_cls.action_echo)) + # classmethod can be decorated with action + self.assertEqual(self.thing_cls.action_echo_with_classmethod, + action()(self.thing_cls.action_echo_with_classmethod)) + self.assertTrue(isclassmethod(self.thing_cls.action_echo_with_classmethod)) + # async methods can be decorated with action + self.assertEqual(self.thing_cls.action_echo_async, + action()(self.thing_cls.action_echo_async)) + # async classmethods can be decorated with action + self.assertEqual(self.thing_cls.action_echo_async_with_classmethod, + action()(self.thing_cls.action_echo_async_with_classmethod)) + self.assertTrue(isclassmethod(self.thing_cls.action_echo_async_with_classmethod)) + # parameterized function can be decorated with action + self.assertEqual(self.thing_cls.typed_action, action(safe=True)(self.thing_cls.typed_action)) + self.assertEqual(self.thing_cls.typed_action_without_call, action(idempotent=True)(self.thing_cls.typed_action_without_call)) + self.assertEqual(self.thing_cls.typed_action_async, action(synchronous=True)(self.thing_cls.typed_action_async)) + + + def test_2_remote_info(self): + # basic check if the remote_info is correct, although this test is not necessary, not recommended and + # neither particularly useful + remote_info = self.thing_cls.action_echo._remote_info + self.assertIsInstance(remote_info, ActionInfoValidator) + assert isinstance(remote_info, ActionInfoValidator) # type definition + self.assertTrue(remote_info.isaction) + self.assertFalse(remote_info.isproperty) + self.assertFalse(remote_info.isparameterized) + self.assertFalse(remote_info.iscoroutine) + self.assertFalse(remote_info.safe) + self.assertFalse(remote_info.idempotent) + self.assertFalse(remote_info.synchronous) + + remote_info = self.thing_cls.action_echo_async._remote_info + self.assertIsInstance(remote_info, ActionInfoValidator) + assert isinstance(remote_info, ActionInfoValidator) # type definition + self.assertTrue(remote_info.isaction) + self.assertTrue(remote_info.iscoroutine) + self.assertFalse(remote_info.isproperty) + self.assertFalse(remote_info.isparameterized) + self.assertFalse(remote_info.safe) + self.assertFalse(remote_info.idempotent) + self.assertFalse(remote_info.synchronous) + + remote_info = self.thing_cls.action_echo_with_classmethod._remote_info + self.assertIsInstance(remote_info, ActionInfoValidator) + assert isinstance(remote_info, ActionInfoValidator) # type definition + self.assertTrue(remote_info.isaction) + self.assertFalse(remote_info.iscoroutine) + self.assertFalse(remote_info.isproperty) + self.assertFalse(remote_info.isparameterized) + self.assertFalse(remote_info.safe) + self.assertFalse(remote_info.idempotent) + self.assertFalse(remote_info.synchronous) + + remote_info = self.thing_cls.typed_action._remote_info + self.assertIsInstance(remote_info, ActionInfoValidator) + assert isinstance(remote_info, ActionInfoValidator) + self.assertTrue(remote_info.isaction) + self.assertFalse(remote_info.iscoroutine) + self.assertFalse(remote_info.isproperty) + self.assertTrue(remote_info.isparameterized) + self.assertTrue(remote_info.safe) + self.assertFalse(remote_info.idempotent) + self.assertFalse(remote_info.synchronous) + + remote_info = self.thing_cls.typed_action_without_call._remote_info + self.assertIsInstance(remote_info, ActionInfoValidator) + assert isinstance(remote_info, ActionInfoValidator) + self.assertTrue(remote_info.isaction) + self.assertFalse(remote_info.iscoroutine) + self.assertFalse(remote_info.isproperty) + self.assertTrue(remote_info.isparameterized) + self.assertFalse(remote_info.safe) + self.assertTrue(remote_info.idempotent) + self.assertFalse(remote_info.synchronous) + + remote_info = self.thing_cls.typed_action_async._remote_info + self.assertIsInstance(remote_info, ActionInfoValidator) + assert isinstance(remote_info, ActionInfoValidator) + self.assertTrue(remote_info.isaction) + self.assertTrue(remote_info.iscoroutine) + self.assertFalse(remote_info.isproperty) + self.assertTrue(remote_info.isparameterized) + self.assertFalse(remote_info.safe) + self.assertFalse(remote_info.idempotent) + self.assertTrue(remote_info.synchronous) + + + def test_3_api_and_invalid_actions(self): + # done allow action decorator to be terminated without '()' on a method + with self.assertRaises(TypeError) as ex: + action(self.thing_cls.incorrectly_decorated_method) + self.assertTrue(str(ex.exception).startswith("URL_path should be a string, not a function/method, did you decorate")) + + # dunder methods cannot be decorated with action + with self.assertRaises(ValueError) as ex: + action()(self.thing_cls.__internal__) + self.assertTrue(str(ex.exception).startswith("dunder objects cannot become remote")) + + # only functions and methods can be decorated with action + for obj in [self.thing_cls, str, 1, 1.0, 'Str', True, None, object(), type, property]: + with self.assertRaises(TypeError) as ex: + action()(obj) # not an action + self.assertTrue(str(ex.exception).startswith("target for action or is not a function/method.")) + + with self.assertRaises(ValueError) as ex: + action(safe=True, some_kw=1) + self.assertTrue(str(ex.exception).startswith("Only 'safe', 'idempotent', 'synchronous' are allowed")) + + + def test_4_exposed_actions(self): + self.assertTrue(hasattr(self.thing_cls.action_echo, '_remote_info')) + done_queue = multiprocessing.Queue() + start_thing_forked(self.thing_cls, instance_name='test-action', done_queue=done_queue, + log_level=logging.ERROR+10, prerun_callback=expose_actions) + + thing_client = ObjectProxy('test-action', log_level=logging.ERROR) # type: TestThing + + self.assertTrue(thing_client.action_echo(1) == 1) + self.assertTrue(thing_client.action_echo_async("string") == "string") + self.assertTrue(thing_client.typed_action(arg1=1, arg2='hello', arg3=5) == ['test-action', 1, 'hello', 5]) + self.assertTrue(thing_client.typed_action_async(arg1=2.5, arg2='hello', arg3='foo') == ['test-action', 2.5, 'hello', 'foo']) + + with self.assertRaises(NotImplementedError) as ex: + thing_client.typed_action_without_call(arg1=1, arg2='hello', arg3=5), + self.assertTrue(str(ex.exception).startswith("Subclasses must implement __call__")) + + with self.assertRaises(AttributeError) as ex: + thing_client.__internal__(1) + self.assertTrue(str(ex.exception).startswith("'ObjectProxy' object has no attribute '__internal__'")) + + with self.assertRaises(AttributeError) as ex: + thing_client.not_an_action("foo") + self.assertTrue(str(ex.exception).startswith("'ObjectProxy' object has no attribute 'not_an_action'")) + + with self.assertRaises(AttributeError) as ex: + thing_client.not_an_async_action(1) + self.assertTrue(str(ex.exception).startswith("'ObjectProxy' object has no attribute 'not_an_async_action'")) + + thing_client.exit() + + self.assertTrue(done_queue.get() == 'test-action') + + + +def expose_actions(thing_cls): + action()(thing_cls.action_echo) + # classmethod can be decorated with action + action()(thing_cls.action_echo_with_classmethod) + # async methods can be decorated with action + action()(thing_cls.action_echo_async) + # async classmethods can be decorated with action + action()(thing_cls.action_echo_async_with_classmethod) + # parameterized function can be decorated with action + action(safe=True)(thing_cls.typed_action) + action(idempotent=True)(thing_cls.typed_action_without_call) + action(synchronous=True)(thing_cls.typed_action_async) + + + +if __name__ == '__main__': + unittest.main(testRunner=TestRunner()) \ No newline at end of file diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..27e5804 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,75 @@ +import logging, threading, time +import unittest +from hololinked.client import ObjectProxy +from hololinked.server import Thing, action, Event +from hololinked.server.properties import Number +try: + from .utils import TestCase, TestRunner + from .things import TestThing +except ImportError: + from utils import TestCase, TestRunner + from things import start_thing_forked + + + +class TestThing(Thing): + + total_number_of_events = Number(default=1, bounds=(1, None), + doc="Total number of events pushed") + + test_event = Event(friendly_name="test-event", doc="A test event", + URL_path='/test-event') + + @action() + def push_events(self): + threading.Thread(target=self._push_worker).start() + + def _push_worker(self): + for i in range(100): + self.test_event.push('test data') + time.sleep(0.01) + + + +class TestEvent(TestCase): + + @classmethod + def setUpClass(self): + print("test event") + self.thing_cls = TestThing + start_thing_forked(self.thing_cls, instance_name='test-event', + log_level=logging.WARN) + self.thing_client = ObjectProxy('test-event') # type: TestThing + + @classmethod + def tearDownClass(self): + print("tear down test event") + self.thing_client.exit() + + + def test_1_event(self): + attempts = 100 + self.thing_client.total_number_of_events = attempts + + results = [] + def cb(value): + results.append(value) + + self.thing_client.test_event.subscribe(cb) + time.sleep(3) + # Calm down for event publisher to connect fully as there is no handshake for events + self.thing_client.push_events() + + for i in range(attempts): + if len(results) == attempts: + break + time.sleep(0.1) + + self.assertEqual(len(results), attempts) + self.assertEqual(results, ['test data']*attempts) + self.thing_client.test_event.unsubscribe(cb) + + + +if __name__ == '__main__': + unittest.main(testRunner=TestRunner()) \ No newline at end of file diff --git a/tests/test_property.py b/tests/test_property.py new file mode 100644 index 0000000..8583a3d --- /dev/null +++ b/tests/test_property.py @@ -0,0 +1,220 @@ +import datetime +import logging +import unittest +import time +import os +from hololinked.client import ObjectProxy +from hololinked.server import action, Thing, global_config +from hololinked.server.properties import Number, String, Selector, List, Integer +from hololinked.server.database import BaseDB +try: + from .utils import TestCase, TestRunner + from .things import start_thing_forked +except ImportError: + from utils import TestCase, TestRunner + from things import start_thing_forked + + + + +class TestThing(Thing): + + number_prop = Number(doc="A fully editable number property") + string_prop = String(default='hello', regex='^[a-z]+', + doc="A string property with a regex constraint to check value errors") + int_prop = Integer(default=5, step=2, bounds=(0, 100), + doc="An integer property with step and bounds constraints to check RW") + selector_prop = Selector(objects=['a', 'b', 'c', 1], default='a', + doc="A selector property to check RW") + observable_list_prop = List(default=None, allow_None=True, observable=True, + doc="An observable list property to check observable events on write operations") + observable_readonly_prop = Number(default=0, readonly=True, observable=True, + doc="An observable readonly property to check observable events on read operations") + db_commit_number_prop = Number(default=0, db_commit=True, + doc="A fully editable number property to check commits to db on write operations") + db_init_int_prop = Integer(default=1, db_init=True, + doc="An integer property to check initialization from db") + db_persist_selector_prop = Selector(objects=['a', 'b', 'c', 1], default='a', db_persist=True, + doc="A selector property to check persistence to db on write operations") + non_remote_number_prop = Number(default=5, remote=False, + doc="A non remote number property to check non-availability on client") + + @observable_readonly_prop.getter + def get_observable_readonly_prop(self): + if not hasattr(self, '_observable_readonly_prop'): + self._observable_readonly_prop = 0 + self._observable_readonly_prop += 1 + return self._observable_readonly_prop + + + @action() + def print_props(self): + print(f'number_prop: {self.number_prop}') + print(f'string_prop: {self.string_prop}') + print(f'int_prop: {self.int_prop}') + print(f'selector_prop: {self.selector_prop}') + print(f'observable_list_prop: {self.observable_list_prop}') + print(f'observable_readonly_prop: {self.observable_readonly_prop}') + print(f'db_commit_number_prop: {self.db_commit_number_prop}') + print(f'db_init_int_prop: {self.db_init_int_prop}') + print(f'db_persist_selctor_prop: {self.db_persist_selector_prop}') + print(f'non_remote_number_prop: {self.non_remote_number_prop}') + + + + +class TestProperty(TestCase): + + @classmethod + def setUpClass(self): + print("test property") + self.thing_cls = TestThing + start_thing_forked(self.thing_cls, instance_name='test-property', + log_level=logging.WARN) + self.thing_client = ObjectProxy('test-property') # type: TestThing + + @classmethod + def tearDownClass(self): + print("tear down test property") + self.thing_client.exit() + + + def test_1_client_api(self): + # Test read + self.assertEqual(self.thing_client.number_prop, 0) + # Test write + self.thing_client.string_prop = 'world' + self.assertEqual(self.thing_client.string_prop, 'world') + # Test exception propagation to client + with self.assertRaises(ValueError): + self.thing_client.string_prop = 'WORLD' + with self.assertRaises(TypeError): + self.thing_client.int_prop = '5' + # Test non remote prop (non-)availability on client + with self.assertRaises(AttributeError): + self.thing_client.non_remote_number_prop + + + def test_2_RW_multiple_properties(self): + # Test partial list of read write properties + self.thing_client.set_properties( + number_prop=15, + string_prop='foobar' + ) + self.assertEqual(self.thing_client.number_prop, 15) + self.assertEqual(self.thing_client.string_prop, 'foobar') + # check prop that was not set in multiple properties + self.assertEqual(self.thing_client.int_prop, 5) + + self.thing_client.selector_prop = 'b' + self.thing_client.number_prop = -15 + props = self.thing_client.get_properties(names=['selector_prop', 'int_prop', + 'number_prop', 'string_prop']) + self.assertEqual(props['selector_prop'], 'b') + self.assertEqual(props['int_prop'], 5) + self.assertEqual(props['number_prop'], -15) + self.assertEqual(props['string_prop'], 'foobar') + + + def test_3_observability(self): + # req 1 - observable events come due to writing a property + propective_values = [ + [1, 2, 3, 4, 5], + ['a', 'b', 'c', 'd', 'e'], + [1, 'a', 2, 'b', 3] + ] + result = [] + attempt = 0 + def cb(value): + nonlocal attempt, result + self.assertEqual(value, propective_values[attempt]) + result.append(value) + attempt += 1 + + self.thing_client.subscribe_event('observable_list_prop_change_event', cb) + time.sleep(3) + # Calm down for event publisher to connect fully as there is no handshake for events + for value in propective_values: + self.thing_client.observable_list_prop = value + + for i in range(20): + if attempt == len(propective_values): + break + # wait for the callback to be called + time.sleep(0.1) + self.thing_client.unsubscribe_event('observable_list_prop_change_event') + + self.assertEqual(result, propective_values) + + # req 2 - observable events come due to reading a property + propective_values = [1, 2, 3, 4, 5] + result = [] + attempt = 0 + def cb(value): + nonlocal attempt, result + self.assertEqual(value, propective_values[attempt]) + result.append(value) + attempt += 1 + + self.thing_client.subscribe_event('observable_readonly_prop_change_event', cb) + time.sleep(3) + # Calm down for event publisher to connect fully as there is no handshake for events + for _ in propective_values: + self.thing_client.observable_readonly_prop + + for i in range(20): + if attempt == len(propective_values): + break + # wait for the callback to be called + time.sleep(0.1) + + self.thing_client.unsubscribe_event('observable_readonly_prop_change_event') + self.assertEqual(result, propective_values) + + + def test_4_db_operations(self): + # remove old file path first + file_path = f'{BaseDB.get_temp_dir_for_class_name(TestThing.__name__)}/test-db-operations.db' + try: + os.remove(file_path) + except (OSError, FileNotFoundError): + pass + self.assertTrue(not os.path.exists(file_path)) + + # test db commit property + thing = TestThing(instance_name='test-db-operations', use_default_db=True, log_level=logging.WARN) + self.assertEqual(thing.db_commit_number_prop, 0) # 0 is default just for reference + thing.db_commit_number_prop = 100 + self.assertEqual(thing.db_commit_number_prop, 100) + self.assertEqual(thing.db_engine.get_property('db_commit_number_prop'), 100) + + # test db persist property + self.assertEqual(thing.db_persist_selector_prop, 'a') # a is default just for reference + thing.db_persist_selector_prop = 'c' + self.assertEqual(thing.db_persist_selector_prop, 'c') + self.assertEqual(thing.db_engine.get_property('db_persist_selector_prop'), 'c') + + # test db init property + self.assertEqual(thing.db_init_int_prop, 1) # 1 is default just for reference + thing.db_init_int_prop = 50 + self.assertEqual(thing.db_init_int_prop, 50) + self.assertNotEqual(thing.db_engine.get_property('db_init_int_prop'), 50) + self.assertEqual(thing.db_engine.get_property('db_init_int_prop'), TestThing.db_init_int_prop.default) + del thing + + # delete thing and reload from database + thing = TestThing(instance_name='test-db-operations', use_default_db=True, log_level=logging.WARN) + self.assertEqual(thing.db_init_int_prop, TestThing.db_init_int_prop.default) + self.assertEqual(thing.db_persist_selector_prop, 'c') + self.assertNotEqual(thing.db_commit_number_prop, 100) + self.assertEqual(thing.db_commit_number_prop, TestThing.db_commit_number_prop.default) + + # check db init prop with a different value in database apart from default + thing.db_engine.set_property('db_init_int_prop', 101) + del thing + thing = TestThing(instance_name='test-db-operations', use_default_db=True, log_level=logging.WARN) + self.assertEqual(thing.db_init_int_prop, 101) + + +if __name__ == '__main__': + unittest.main(testRunner=TestRunner()) diff --git a/tests/test_rpc.py b/tests/test_rpc.py new file mode 100644 index 0000000..5b16853 --- /dev/null +++ b/tests/test_rpc.py @@ -0,0 +1,261 @@ +import threading, random, asyncio, requests +import logging, multiprocessing, unittest +from hololinked.client import ObjectProxy + +try: + from .utils import TestCase, TestRunner + from .things import TestThing, start_thing_forked +except ImportError: + from utils import TestCase, TestRunner + from things import TestThing, start_thing_forked + + + +class TestRPC(TestCase): + + @classmethod + def setUpClass(self): + print("test RPC") + self.thing_cls = TestThing + start_thing_forked( + thing_cls=self.thing_cls, + instance_name='test-rpc', + log_level=logging.WARN, + protocols=['IPC', 'TCP'], + tcp_socket_address='tcp://*:58000', + http_server=True + ) + self.thing_client = ObjectProxy('test-rpc') # type: TestThing + + @classmethod + def tearDownClass(self): + print("tear down test RPC") + self.thing_client.exit() + + + def test_1_normal_client(self): + # First test a simple single-threaded client and make sure it succeeds + # all requests + done_queue = multiprocessing.Queue() + start_client(done_queue) + self.assertEqual(done_queue.get(), True) + + def test_2_threaded_client(self): + # Then test a multi-threaded client and make sure it succeeds all requests + done_queue = multiprocessing.Queue() + start_client(done_queue, 'threading') + self.assertEqual(done_queue.get(), True) + + def test_3_async_client(self): + # Then an async client + done_queue = multiprocessing.Queue() + start_client(done_queue, 'async') + self.assertEqual(done_queue.get(), True) + + def test_4_async_multiple_client(self): + # Then an async client with multiple coroutines/futures + done_queue = multiprocessing.Queue() + start_client(done_queue, 'async_multiple') + self.assertEqual(done_queue.get(), True) + + def test_5_http_client(self): + # Then a HTTP client which uses a message mapped ZMQ client pool on the HTTP server + done_queue = multiprocessing.Queue() + start_client(done_queue, 'http') + self.assertEqual(done_queue.get(), True) + + def test_6_tcp_client(self): + # Also, for sake, a TCP client + done_queue = multiprocessing.Queue() + start_client(done_queue, tcp_socket_address='tcp://localhost:58000') + self.assertEqual(done_queue.get(), True) + + + def test_7_multiple_clients(self): + # Then parallely run all of them at once and make sure they all succeed + # which means the server can request accept from anywhere at any time and not fail + done_queue_1 = multiprocessing.Queue() + start_client(done_queue_1) + + done_queue_2 = multiprocessing.Queue() + start_client(done_queue_2) + + done_queue_3 = multiprocessing.Queue() + start_client(done_queue_3, 'threading') + + done_queue_4 = multiprocessing.Queue() + start_client(done_queue_4, 'async') + + done_queue_5 = multiprocessing.Queue() + start_client(done_queue_5, 'async_multiple') + + done_queue_6 = multiprocessing.Queue() + start_client(done_queue_6, 'http') + + done_queue_7 = multiprocessing.Queue() + start_client(done_queue_7, typ='threading', tcp_socket_address='tcp://localhost:58000') + + done_queue_8 = multiprocessing.Queue() + start_client(done_queue_8, tcp_socket_address='tcp://localhost:58000') + + self.assertEqual(done_queue_1.get(), True) + self.assertEqual(done_queue_2.get(), True) + self.assertEqual(done_queue_3.get(), True) + self.assertEqual(done_queue_4.get(), True) + self.assertEqual(done_queue_5.get(), True) + self.assertEqual(done_queue_6.get(), True) + self.assertEqual(done_queue_7.get(), True) + self.assertEqual(done_queue_8.get(), True) + + + +def start_client(done_queue : multiprocessing.Queue, typ : str = 'normal', tcp_socket_address : str = None): + if typ == 'normal': + return multiprocessing.Process(target=normal_client, args=(done_queue, tcp_socket_address)).start() + elif typ == 'threading': + return multiprocessing.Process(target=threading_client, args=(done_queue, tcp_socket_address)).start() + elif typ == 'async': + return multiprocessing.Process(target=async_client, args=(done_queue,)).start() + elif typ == 'async_multiple': + return multiprocessing.Process(target=async_client_multiple, args=(done_queue,)).start() + elif typ == 'http': + return multiprocessing.Process(target=http_client, args=(done_queue,)).start() + raise NotImplementedError(f"client type {typ} not implemented or unknown.") + + +def gen_random_data(): + choice = random.randint(0, 1) + if choice == 0: # float + return random.random()*1000 + elif choice == 1: + return random.choice(['a', True, False, 10, 55e-3, [i for i in range(100)], {'a': 1, 'b': 2}, + None]) + + +def normal_client(done_queue : multiprocessing.Queue = None, tcp_socket_address : str = None): + success = True + if tcp_socket_address: + client = ObjectProxy('test-rpc', socket_address=tcp_socket_address, protocol='TCP') # type: TestThing + else: + client = ObjectProxy('test-rpc') # type: TestThing + for i in range(2000): + value = gen_random_data() + ret = client.test_echo(value) + # print("single-thread", 1, i, value, ret) + if value != ret: + print("error", "single-thread", 1, i, value, ret) + success = False + break + + if done_queue is not None: + done_queue.put(success) + + +def threading_client(done_queue : multiprocessing.Queue = None, tcp_socket_address : str = None): + success = True + if tcp_socket_address: + client = ObjectProxy('test-rpc', socket_address=tcp_socket_address, protocol='TCP') # type: TestThing + else: + client = ObjectProxy('test-rpc') # type: TestThing + + def message_thread(id : int): + nonlocal success, client + for i in range(1000): + value = gen_random_data() + ret = client.test_echo(value) + # print("multi-threaded", id, i, value, ret) + if value != ret: + print("error", "multi-threaded", 1, i, value, ret) + success = False + break + + T1 = threading.Thread(target=message_thread, args=(1,)) + T2 = threading.Thread(target=message_thread, args=(2,)) + T3 = threading.Thread(target=message_thread, args=(3,)) + T1.start() + T2.start() + T3.start() + T1.join() + T2.join() + T3.join() + + if done_queue is not None: + done_queue.put(success) + + +def async_client(done_queue : multiprocessing.Queue = None): + success = True + client = ObjectProxy('test-rpc', async_mixin=True) # type: TestThing + + async def message_coro(): + nonlocal success, client + for i in range(2000): + value = gen_random_data() + ret = await client.async_invoke('test_echo', value) + # print("async", 1, i, value, ret) + if value != ret: + print("error", "async", 1, i, value, ret) + success = False + break + + asyncio.run(message_coro()) + if done_queue is not None: + done_queue.put(success) + + +def async_client_multiple(done_queue : multiprocessing.Queue = None): + success = True + client = ObjectProxy('test-rpc', async_mixin=True) # type: TestThing + + async def message_coro(id): + nonlocal success, client + for i in range(1000): + value = gen_random_data() + ret = await client.async_invoke('test_echo', value) + # print("multi-coro", id, i, value, ret) + if value != ret: + print("error", "multi-coro", id, i, value, ret) + success = False + break + + asyncio.get_event_loop().run_until_complete( + asyncio.gather(*[message_coro(1), message_coro(2), message_coro(3)])) + if done_queue is not None: + done_queue.put(success) + + +def http_client(done_queue : multiprocessing.Queue = None): + success = True + session = requests.Session() + + def worker(id : int): + nonlocal success + for i in range(1000): + value = gen_random_data() + ret = session.post( + 'http://localhost:8080/test-rpc/test-echo', + json={'value': value}, + headers={'Content-Type': 'application/json'} + ) + # print("http", id, i, value, ret) + if value != ret.json(): + print("http", id, i, value, ret) + success = False + break + + T1 = threading.Thread(target=worker, args=(1,)) + T2 = threading.Thread(target=worker, args=(2,)) + T1.start() + T2.start() + T1.join() + T2.join() + + if done_queue is not None: + done_queue.put(success) + + + + + +if __name__ == '__main__': + unittest.main(testRunner=TestRunner()) diff --git a/tests/test_thing_init.py b/tests/test_thing_init.py new file mode 100644 index 0000000..f555905 --- /dev/null +++ b/tests/test_thing_init.py @@ -0,0 +1,203 @@ +import unittest +import logging +import warnings + +from hololinked.server import Thing +from hololinked.server.schema_validators import JsonSchemaValidator, BaseSchemaValidator +from hololinked.server.serializers import JSONSerializer, PickleSerializer, MsgpackSerializer +from hololinked.server.utils import get_default_logger +from hololinked.server.logger import RemoteAccessHandler +from hololinked.client import ObjectProxy +try: + from .things import OceanOpticsSpectrometer, start_thing_forked + from .utils import TestCase +except ImportError: + from things import OceanOpticsSpectrometer, start_thing_forked + from utils import TestCase + + +class TestThing(TestCase): + """Test Thing class from hololinked.server.thing module.""" + + @classmethod + def setUpClass(self): + print("test Thing init") + self.thing_cls = Thing + + @classmethod + def tearDownClass(self) -> None: + print("tear down test Thing init") + + def test_1_instance_name(self): + # instance name must be a string and cannot be changed after set + thing = self.thing_cls(instance_name="test_instance_name", log_level=logging.WARN) + self.assertEqual(thing.instance_name, "test_instance_name") + with self.assertRaises(ValueError): + thing.instance_name = "new_instance" + with self.assertRaises(NotImplementedError): + del thing.instance_name + + + def test_2_logger(self): + # logger must have remote access handler if logger_remote_access is True + logger = get_default_logger("test_logger", log_level=logging.WARN) + thing = self.thing_cls(instance_name="test_logger_remote_access", logger=logger, logger_remote_access=True) + self.assertEqual(thing.logger, logger) + self.assertTrue(any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers)) + + # Therefore also check the false condition + logger = get_default_logger("test_logger_2", log_level=logging.WARN) + thing = self.thing_cls(instance_name="test_logger_without_remote_access", logger=logger, logger_remote_access=False) + self.assertFalse(any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers)) + # NOTE - logger is modifiable after instantiation + # What if user gives his own remote access handler? + + + def test_3_JSON_serializer(self): + # req 1 - if serializer is not provided, default is JSONSerializer and http and zmq serializers are same + thing = self.thing_cls(instance_name="test_serializer_when_not_provided", log_level=logging.WARN) + self.assertIsInstance(thing.zmq_serializer, JSONSerializer) + self.assertEqual(thing.http_serializer, thing.zmq_serializer) + + # req 2 - similarly, serializer keyword argument creates same serialitzer for both zmq and http transports + serializer = JSONSerializer() + thing = self.thing_cls(instance_name="test_common_serializer", serializer=serializer, log_level=logging.WARN) + self.assertEqual(thing.zmq_serializer, serializer) + self.assertEqual(thing.http_serializer, serializer) + + # req 3 - serializer keyword argument must be JSONSerializer only, because this keyword should + # what is common to both zmq and http + with self.assertRaises(TypeError) as ex: + serializer = PickleSerializer() + thing = self.thing_cls(instance_name="test_common_serializer_nonJSON", serializer=serializer, log_level=logging.WARN) + self.assertTrue(str(ex), "serializer key word argument must be JSONSerializer") + + # req 4 - zmq_serializer and http_serializer is differently instantiated if zmq_serializer and http_serializer + # keyword arguments are provided, albeit the same serializer type + serializer = JSONSerializer() + thing = self.thing_cls(instance_name="test_common_serializer", zmq_serializer=serializer, log_level=logging.WARN) + self.assertEqual(thing.zmq_serializer, serializer) + self.assertNotEqual(thing.http_serializer, serializer) # OR, same as line below + self.assertNotEqual(thing.http_serializer, thing.zmq_serializer) + self.assertIsInstance(thing.http_serializer, JSONSerializer) + + + def test_4_other_serializers(self): + # req 1 - http_serializer cannot be anything except than JSON + with self.assertRaises(ValueError) as ex: + # currenty this has written this as ValueError although TypeError is more appropriate + serializer = PickleSerializer() + thing = self.thing_cls(instance_name="test_http_serializer_nonJSON", http_serializer=serializer, + log_level=logging.WARN) + self.assertTrue(str(ex), "invalid JSON serializer option") + # test the same with MsgpackSerializer + with self.assertRaises(ValueError) as ex: + # currenty this has written this as ValueError although TypeError is more appropriate + serializer = MsgpackSerializer() + thing = self.thing_cls(instance_name="test_http_serializer_nonJSON", http_serializer=serializer, + log_level=logging.WARN) + self.assertTrue(str(ex), "invalid JSON serializer option") + + # req 2 - http_serializer and zmq_serializer can be different + warnings.filterwarnings("ignore", category=UserWarning) + http_serializer = JSONSerializer() + zmq_serializer = PickleSerializer() + thing = self.thing_cls(instance_name="test_different_serializers_1", http_serializer=http_serializer, + zmq_serializer=zmq_serializer, log_level=logging.WARN) + self.assertNotEqual(thing.http_serializer, thing.zmq_serializer) + self.assertEqual(thing.http_serializer, http_serializer) + self.assertEqual(thing.zmq_serializer, zmq_serializer) + warnings.resetwarnings() + + # try the same with MsgpackSerializer + http_serializer = JSONSerializer() + zmq_serializer = MsgpackSerializer() + thing = self.thing_cls(instance_name="test_different_serializers_2", http_serializer=http_serializer, + zmq_serializer=zmq_serializer, log_level=logging.WARN) + self.assertNotEqual(thing.http_serializer, thing.zmq_serializer) + self.assertEqual(thing.http_serializer, http_serializer) + self.assertEqual(thing.zmq_serializer, zmq_serializer) + + # req 3 - pickle serializer should raise warning + http_serializer = JSONSerializer() + zmq_serializer = PickleSerializer() + with self.assertWarns(expected_warning=UserWarning): + thing = self.thing_cls(instance_name="test_pickle_serializer_warning", http_serializer=http_serializer, + zmq_serializer=zmq_serializer, log_level=logging.WARN) + + + def test_5_schema_validator(self): + # schema_validator must be a class or subclass of BaseValidator + validator = JsonSchemaValidator(schema=True) + with self.assertRaises(ValueError): + thing = self.thing_cls(instance_name="test_schema_validator_with_instance", schema_validator=validator) + + validator = JsonSchemaValidator + thing = self.thing_cls(instance_name="test_schema_validator_with_subclass", schema_validator=validator, + log_level=logging.WARN) + self.assertEqual(thing.schema_validator, validator) + + validator = BaseSchemaValidator + thing = self.thing_cls(instance_name="test_schema_validator_with_subclass", schema_validator=validator, + log_level=logging.WARN) + self.assertEqual(thing.schema_validator, validator) + + + def test_6_state(self): + # state property must be None when no state machine is present + thing = self.thing_cls(instance_name="test_no_state_machine", log_level=logging.WARN) + self.assertIsNone(thing.state) + self.assertFalse(hasattr(thing, 'state_machine')) + # detailed tests should be in another file + + + def test_7_servers_init(self): + # rpc_server, message_broker and event_publisher must be None when not run() + thing = self.thing_cls(instance_name="test_servers_init", log_level=logging.WARN) + self.assertIsNone(thing.rpc_server) + self.assertIsNone(thing.message_broker) + self.assertIsNone(thing.event_publisher) + + + def test_8_resource_generation(self): + # basic test only to make sure nothing is fundamentally wrong + thing = self.thing_cls(instance_name="test_servers_init", log_level=logging.WARN) + self.assertIsInstance(thing.get_thing_description(), dict) + self.assertIsInstance(thing.httpserver_resources, dict) + self.assertIsInstance(thing.zmq_resources, dict) + + start_thing_forked(self.thing_cls, instance_name='test-gui-resource-generation', log_level=logging.WARN) + thing_client = ObjectProxy('test-gui-resource-generation') + self.assertIsInstance(thing_client.gui_resources, dict) + thing_client.exit() + + + +class TestOceanOpticsSpectrometer(TestThing): + + @classmethod + def setUpClass(self): + print("test OceanOpticsSpectrometer init") + self.thing_cls = OceanOpticsSpectrometer + + @classmethod + def tearDownClass(self) -> None: + print("tear down test OceanOpticsSpectrometer init") + + def test_6_state(self): + # req 1 - state property must be None when no state machine is present + thing = self.thing_cls(instance_name="test_state_machine", log_level=logging.WARN) + self.assertIsNotNone(thing.state) + self.assertTrue(hasattr(thing, 'state_machine')) + # detailed tests should be in another file + + + +if __name__ == '__main__': + try: + from utils import TestRunner + except ImportError: + from .utils import TestRunner + + unittest.main(testRunner=TestRunner()) + diff --git a/tests/test_thing_run.py b/tests/test_thing_run.py new file mode 100644 index 0000000..d778350 --- /dev/null +++ b/tests/test_thing_run.py @@ -0,0 +1,98 @@ +import threading +import typing +import unittest +import multiprocessing +import logging +import zmq.asyncio + +from hololinked.server import Thing +from hololinked.client import ObjectProxy +from hololinked.server.eventloop import EventLoop +try: + from .things import TestThing, OceanOpticsSpectrometer + from .utils import TestCase +except ImportError: + from things import TestThing, OceanOpticsSpectrometer + from utils import TestCase + + +class TestThingRun(TestCase): + + @classmethod + def setUpClass(self): + print("test Thing run") + self.thing_cls = Thing + + @classmethod + def tearDownClass(self): + # Code to clean up any resources or configurations after each test case + print("tear down test Thing run") + + def test_thing_run_and_exit(self): + # should be able to start and end with exactly the specified protocols + done_queue = multiprocessing.Queue() + multiprocessing.Process(target=start_thing, args=('test-run', ), kwargs=dict(done_queue=done_queue), + daemon=True).start() + thing_client = ObjectProxy('test-run', log_level=logging.WARN) # type: Thing + self.assertEqual(thing_client.get_protocols(), ['IPC']) + thing_client.exit() + self.assertEqual(done_queue.get(), 'test-run') + + done_queue = multiprocessing.Queue() + multiprocessing.Process(target=start_thing, args=('test-run-2', ['IPC', 'INPROC'],), + kwargs=dict(done_queue=done_queue), daemon=True).start() + thing_client = ObjectProxy('test-run-2', log_level=logging.WARN) # type: Thing + self.assertEqual(thing_client.get_protocols(), ['INPROC', 'IPC']) # order should reflect get_protocols() action + thing_client.exit() + self.assertEqual(done_queue.get(), 'test-run-2') + + done_queue = multiprocessing.Queue() + multiprocessing.Process(target=start_thing, args=('test-run-3', ['IPC', 'INPROC', 'TCP'], 'tcp://*:59000'), + kwargs=dict(done_queue=done_queue), daemon=True).start() + thing_client = ObjectProxy('test-run-3', log_level=logging.WARN) # type: Thing + self.assertEqual(thing_client.get_protocols(), ['INPROC', 'IPC', 'TCP']) + thing_client.exit() + self.assertEqual(done_queue.get(), 'test-run-3') + + + # def test_thing_run_and_exit_with_httpserver(self): + # EventLoop.get_async_loop() # creates the event loop if absent + # context = zmq.asyncio.Context() + # T = threading.Thread(target=start_thing_with_http_server, args=('test-run-4', context), daemon=True) + # T.start() + # # difficult case, currently not supported - https://github.com/zeromq/pyzmq/issues/1354 + # thing_client = ObjectProxy('test-run-4', log_level=logging.WARN, context=context) # type: Thing + # self.assertEqual(thing_client.get_protocols(), ['INPROC']) + # thing_client.exit() + # T.join() + + +class TestOceanOpticsSpectrometer(TestThing): + + @classmethod + def setUpClass(self): + self.thing_cls = OceanOpticsSpectrometer + + + + +def start_thing(instance_name : str, protocols : typing.List[str] = ['IPC'], tcp_socket_address : str = None, + done_queue : typing.Optional[multiprocessing.Queue] = None) -> None: + thing = TestThing(instance_name=instance_name) #, log_level=logging.WARN) + thing.run(zmq_protocols=protocols, tcp_socket_address=tcp_socket_address) + if done_queue is not None: + done_queue.put(instance_name) + + +def start_thing_with_http_server(instance_name : str, context : zmq.asyncio.Context) -> None: + EventLoop.get_async_loop() # creates the event loop if absent + thing = TestThing(instance_name=instance_name)# , log_level=logging.WARN) + thing.run_with_http_server(context=context) + + +if __name__ == '__main__': + try: + from utils import TestRunner + except ImportError: + from .utils import TestRunner + unittest.main(testRunner=TestRunner()) diff --git a/tests/things/__init__.py b/tests/things/__init__.py new file mode 100644 index 0000000..f6b62c1 --- /dev/null +++ b/tests/things/__init__.py @@ -0,0 +1,4 @@ +from .test_thing import TestThing +from .spectrometer import OceanOpticsSpectrometer +from .starter import start_thing_forked + diff --git a/tests/things/spectrometer.py b/tests/things/spectrometer.py new file mode 100644 index 0000000..22c9cc2 --- /dev/null +++ b/tests/things/spectrometer.py @@ -0,0 +1,289 @@ +import datetime +from enum import StrEnum +import threading +import typing +import numpy +from dataclasses import dataclass + + +from hololinked.server import Thing, Property, action, Event +from hololinked.server.properties import (String, Integer, Number, List, Boolean, + Selector, ClassSelector, TypedList) +from hololinked.server import HTTP_METHODS, StateMachine +from hololinked.server import JSONSerializer +from hololinked.server.td import JSONSchema + + +@dataclass +class Intensity: + value : numpy.ndarray + timestamp : str + + schema = { + "type" : "object", + "properties" : { + "value" : { + "type" : "array", + "items" : { + "type" : "number" + }, + }, + "timestamp" : { + "type" : "string" + } + } + } + + @property + def not_completely_black(self): + if any(self.value[i] > 0 for i in range(len(self.value))): + return True + return False + + + +JSONSerializer.register_type_replacement(numpy.ndarray, lambda obj : obj.tolist()) +JSONSchema.register_type_replacement(Intensity, 'object', Intensity.schema) + + +connect_args = { + "type": "object", + "properties": { + "serial_number": {"type": "string"}, + "trigger_mode": {"type": "integer"}, + "integration_time": {"type": "number"} + }, + "additionalProperties": False +} + + + +class States(StrEnum): + DISCONNECTED = "DISCONNECTED" + ON = "ON" + FAULT = "FAULT" + MEASURING = "MEASURING" + ALARM = "ALARM" + + +class OceanOpticsSpectrometer(Thing): + """ + OceanOptics spectrometers Test Thing. + """ + + states = States + + status = String(URL_path='/status', readonly=True, fget=lambda self: self._status, + doc="descriptive status of current operation") # type: str + + serial_number = String(default=None, allow_None=True, URL_path='/serial-number', + doc="serial number of the spectrometer to connect/or connected")# type: str + + last_intensity = ClassSelector(default=None, allow_None=True, class_=Intensity, + URL_path='/intensity', doc="last measurement intensity (in arbitrary units)") # type: Intensity + + intensity_measurement_event = Event(friendly_name='intensity-measurement-event', URL_path='/intensity/measurement-event', + doc="event generated on measurement of intensity, max 30 per second even if measurement is faster.", + schema=Intensity.schema) + + reference_intensity = ClassSelector(default=None, allow_None=True, class_=Intensity, + URL_path="/intensity/reference", doc="reference intensity to overlap in background") # type: Intensity + + + def __init__(self, instance_name : str, serial_number : typing.Optional[str] = None, **kwargs) -> None: + super().__init__(instance_name=instance_name, serial_number=serial_number, **kwargs) + if serial_number is not None: + self.connect() + self._acquisition_thread = None + self._running = False + + def set_status(self, *args) -> None: + if len(args) == 1: + self._status = args[0] + else: + self._status = ' '.join(args) + + @action(URL_path='/connect', http_method=HTTP_METHODS.POST, input_schema=connect_args) + def connect(self, serial_number : str = None, trigger_mode : int = None, integration_time : float = None) -> None: + if serial_number is not None: + self.serial_number = serial_number + self.state_machine.current_state = self.states.ON + self._pixel_count = 50 + self._wavelengths = [i for i in range(self._pixel_count)] + self._model = 'STS' + self._max_intensity = 16384 + if trigger_mode is not None: + self.trigger_mode = trigger_mode + else: + self.trigger_mode = self.trigger_mode + # Will set default value of property + if integration_time is not None: + self.integration_time = integration_time + else: + self.integration_time = self.integration_time + # Will set default value of property + self.logger.debug(f"opened device with serial number {self.serial_number} with model {self.model}") + self.set_status("ready to start acquisition") + + model = String(default=None, URL_path='/model', allow_None=True, readonly=True, + doc="model of the connected spectrometer", + fget=lambda self: self._model if self.state_machine.current_state != self.states.DISCONNECTED else None + ) # type: str + + wavelengths = List(default=None, allow_None=True, item_type=(float, int), readonly=True, + URL_path='/supported-wavelengths', doc="wavelength bins of measurement", + fget=lambda self: self._wavelengths if self.state_machine.current_state != self.states.DISCONNECTED else None, + ) # type: typing.List[typing.Union[float, int]] + + pixel_count = Integer(default=None, allow_None=True, URL_path='/pixel-count', readonly=True, + doc="number of points in wavelength", + fget=lambda self: self._pixel_count if self.state_machine.current_state != self.states.DISCONNECTED else None + ) # type: int + + max_intensity = Number(readonly=True, URL_path="/intensity/max-allowed", + doc="""the maximum intensity that can be returned by the spectrometer in (a.u.). + It's possible that the spectrometer saturates already at lower values.""", + fget=lambda self: self._max_intensity if self.state_machine.current_state != self.states.DISCONNECTED else None + ) # type: float + + @action(URL_path='/disconnect', http_method=HTTP_METHODS.POST) + def disconnect(self): + self.state_machine.current_state = self.states.DISCONNECTED + + trigger_mode = Selector(objects=[0, 1, 2, 3, 4], default=0, URL_path='/trigger-mode', observable=True, + doc="""0 = normal/free running, 1 = Software trigger, 2 = Ext. Trigger Level, + 3 = Ext. Trigger Synchro/ Shutter mode, 4 = Ext. Trigger Edge""") # type: int + + @trigger_mode.setter + def apply_trigger_mode(self, value : int): + self._trigger_mode = value + + @trigger_mode.getter + def get_trigger_mode(self): + try: + return self._trigger_mode + except: + return self.properties["trigger_mode"].default + + + integration_time = Number(default=1000, bounds=(0.001, None), crop_to_bounds=True, + URL_path='/integration-time', observable=True, + doc="integration time of measurement in milliseconds") # type: float + + @integration_time.setter + def apply_integration_time(self, value : float): + self._integration_time = int(value) + + @integration_time.getter + def get_integration_time(self) -> float: + try: + return self._integration_time + except: + return self.properties["integration_time"].default + + background_correction = Selector(objects=['AUTO', 'CUSTOM', None], default=None, allow_None=True, + URL_path='/background-correction', + doc="set True for Seabreeze internal black level correction") # type: typing.Optional[str] + + custom_background_intensity = TypedList(item_type=(float, int), + URL_path='/background-correction/user-defined-intensity') # type: typing.List[typing.Union[float, int]] + + nonlinearity_correction = Boolean(default=False, URL_path='/nonlinearity-correction', + doc="automatic correction of non linearity in detector CCD") # type: bool + + @action(URL_path='/acquisition/start', http_method=HTTP_METHODS.POST) + def start_acquisition(self) -> None: + self.stop_acquisition() # Just a shield + self._acquisition_thread = threading.Thread(target=self.measure) + self._acquisition_thread.start() + + @action(URL_path='/acquisition/stop', http_method=HTTP_METHODS.POST) + def stop_acquisition(self) -> None: + if self._acquisition_thread is not None: + self.logger.debug(f"stopping acquisition thread with thread-ID {self._acquisition_thread.ident}") + self._running = False # break infinite loop + # Reduce the measurement that will proceed in new trigger mode to 1ms + self._acquisition_thread.join() + self._acquisition_thread = None + # re-apply old values + self.trigger_mode = self.trigger_mode + self.integration_time = self.integration_time + + + def measure(self, max_count = None): + try: + self._running = True + self.state_machine.current_state = self.states.MEASURING + self.set_status("measuring") + self.logger.info(f'starting continuous acquisition loop with trigger mode {self.trigger_mode} & integration time {self.integration_time} in thread with ID {threading.get_ident()}') + loop = 0 + while self._running: + if max_count is not None and loop > max_count: + break + loop += 1 + # Following is a blocking command - self.spec.intensities + self.logger.debug(f'starting measurement count {loop}') + _current_intensity = [numpy.random.randint(0, self.max_intensity) for i in range(self._pixel_count)] + if self.background_correction == 'CUSTOM': + if self.custom_background_intensity is None: + self.logger.warn('no background correction possible') + self.state_machine.set_state(self.states.ALARM) + else: + _current_intensity = _current_intensity - self.custom_background_intensity + + curtime = datetime.datetime.now() + timestamp = curtime.strftime('%d.%m.%Y %H:%M:%S.') + '{:03d}'.format(int(curtime.microsecond /1000)) + self.logger.debug(f'measurement taken at {timestamp} - measurement count {loop}') + + if self._running: + # To stop the acquisition in hardware trigger mode, we set running to False in stop_acquisition() + # and then change the trigger mode for self.spec.intensities to unblock. This exits this + # infintie loop. Therefore, to know, whether self.spec.intensities finished, whether due to trigger + # mode or due to actual completion of measurement, we check again if self._running is True. + self.last_intensity = Intensity( + value=_current_intensity, + timestamp=timestamp + ) + if self.last_intensity.not_completely_black: + self.intensity_measurement_event.push(self.last_intensity) + self.state_machine.current_state = self.states.MEASURING + else: + self.logger.warn('trigger delayed or no trigger or erroneous data - completely black') + self.state_machine.current_state = self.states.ALARM + if self.state_machine.current_state not in [self.states.FAULT, self.states.ALARM]: + self.state_machine.current_state = self.states.ON + self.set_status("ready to start acquisition") + self.logger.info("ending continuous acquisition") + self._running = False + except Exception as ex: + self.logger.error(f"error during acquisition - {str(ex)}, {type(ex)}") + self.set_status(f'error during acquisition - {str(ex)}, {type(ex)}') + self.state_machine.current_state = self.states.FAULT + + @action(URL_path='/acquisition/single', http_method=HTTP_METHODS.POST) + def start_acquisition_single(self): + self.stop_acquisition() # Just a shield + self._acquisition_thread = threading.Thread(target=self.measure, args=(1,)) + self._acquisition_thread.start() + self.logger.info("data event will be pushed once acquisition is complete.") + + @action(URL_path='/reset-fault', http_method=HTTP_METHODS.POST) + def reset_fault(self): + self.state_machine.set_state(self.states.ON) + + @action() + def test_echo(self, value): + return value + + state_machine = StateMachine( + states=states, + initial_state=states.DISCONNECTED, + push_state_change_event=True, + DISCONNECTED=[connect, serial_number], + ON=[start_acquisition, start_acquisition_single, disconnect, + integration_time, trigger_mode, background_correction, nonlinearity_correction], + MEASURING=[stop_acquisition], + FAULT=[stop_acquisition, reset_fault] + ) + + logger_remote_access = True \ No newline at end of file diff --git a/tests/things/starter.py b/tests/things/starter.py new file mode 100644 index 0000000..d29156a --- /dev/null +++ b/tests/things/starter.py @@ -0,0 +1,104 @@ +import typing, multiprocessing, threading, logging, queue +from hololinked.server import HTTPServer, ThingMeta, Thing + + +def run_thing( + thing_cls : ThingMeta, + instance_name : str, + protocols : typing.List[str] = ['IPC'], + tcp_socket_address : str = None, + done_queue : typing.Optional[multiprocessing.Queue] = None, + log_level : int = logging.WARN, + prerun_callback : typing.Optional[typing.Callable] = None +) -> None: + if prerun_callback: + prerun_callback(thing_cls) + thing = thing_cls(instance_name=instance_name, log_level=log_level) # type: Thing + thing.run(zmq_protocols=protocols, tcp_socket_address=tcp_socket_address) + if done_queue is not None: + done_queue.put(instance_name) + + +def run_thing_with_http_server( + thing_cls : ThingMeta, + instance_name : str, + done_queue : queue.Queue = None, + log_level : int = logging.WARN, + prerun_callback : typing.Optional[typing.Callable] = None +) -> None: + if prerun_callback: + prerun_callback(thing_cls) + thing = thing_cls(instance_name=instance_name, log_level=log_level) # type: Thing + thing.run_with_http_server() + if done_queue is not None: + done_queue.put(instance_name) + + +def start_http_server(instance_name : str) -> None: + H = HTTPServer([instance_name], log_level=logging.WARN) + H.listen() + + +def start_thing_forked( + thing_cls : ThingMeta, + instance_name : str, + protocols : typing.List[str] = ['IPC'], + tcp_socket_address : str = None, + done_queue : typing.Optional[multiprocessing.Queue] = None, + log_level : int = logging.WARN, + prerun_callback : typing.Optional[typing.Callable] = None, + as_process : bool = True, + http_server : bool = False +): + if as_process: + P = multiprocessing.Process( + target=run_thing, + kwargs=dict( + thing_cls=thing_cls, + instance_name=instance_name, + protocols=protocols, + tcp_socket_address=tcp_socket_address, + done_queue=done_queue, + log_level=log_level, + prerun_callback=prerun_callback + ), daemon=True + ) + P.start() + if not http_server: + return P + multiprocessing.Process( + target=start_http_server, + args=(instance_name,), + daemon=True + ).start() + return P + else: + if http_server: + T = threading.Thread( + target=run_thing_with_http_server, + kwargs=dict( + thing_cls=thing_cls, + instance_name=instance_name, + done_queue=done_queue, + log_level=log_level, + prerun_callback=prerun_callback + ) + ) + else: + T = threading.Thread( + target=run_thing, + kwargs=dict( + thing_cls=thing_cls, + instance_name=instance_name, + protocols=protocols, + tcp_socket_address=tcp_socket_address, + done_queue=done_queue, + log_level=log_level, + prerun_callback=prerun_callback + ), daemon=True + ) + T.start() + return T + + + \ No newline at end of file diff --git a/tests/things/test_thing.py b/tests/things/test_thing.py new file mode 100644 index 0000000..ce248d7 --- /dev/null +++ b/tests/things/test_thing.py @@ -0,0 +1,19 @@ +from hololinked.server import Thing, action + + +class TestThing(Thing): + + @action() + def get_protocols(self): + protocols = [] + if self.rpc_server.inproc_server is not None and self.rpc_server.inproc_server.socket_address.startswith('inproc://'): + protocols.append('INPROC') + if self.rpc_server.ipc_server is not None and self.rpc_server.ipc_server.socket_address.startswith('ipc://'): + protocols.append('IPC') + if self.rpc_server.tcp_server is not None and self.rpc_server.tcp_server.socket_address.startswith('tcp://'): + protocols.append('TCP') + return protocols + + @action() + def test_echo(self, value): + return value \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..5aec5db --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,38 @@ +import threading +import unittest + + + +class TestResult(unittest.TextTestResult): + def addSuccess(self, test): + super().addSuccess(test) + self.stream.write(f' {test} ✔') + self.stream.flush() + + def addFailure(self, test, err): + super().addFailure(test, err) + self.stream.write(f' {test} ❌') + self.stream.flush() + + def addError(self, test, err): + super().addError(test, err) + self.stream.write(f' {test} ❌ Error') + self.stream.flush() + +class TestRunner(unittest.TextTestRunner): + resultclass = TestResult + + +class TestCase(unittest.TestCase): + + def setUp(self): + print() # dont concatenate with results printed by unit test + + +def print_lingering_threads(exclude_daemon=True): + alive_threads = threading.enumerate() + if exclude_daemon: + alive_threads = [t for t in alive_threads if not t.daemon] + + for thread in alive_threads: + print(f"Thread Name: {thread.name}, Thread ID: {thread.ident}, Is Alive: {thread.is_alive()}")