From f0b177f55f9ac4f1f1b177e7295bb2058e8ecc74 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Sun, 16 Oct 2022 11:48:08 -0700 Subject: [PATCH] Initial commit --- .github/workflows/publish.yaml | 37 +++++ .github/workflows/test.yaml | 55 ++++++++ .github/workflows/test_publish.yaml | 38 +++++ .gitignore | 124 ++++++++++++++++ CONTRIBUTING.md | 1 + LICENSE.md | 202 +++++++++++++++++++++++++++ README.md | 3 + pyproject.toml | 47 +++++++ requirements.txt | 0 src/uas_standards/__init__.py | 0 src/uas_standards/ansi_cta_2063_a.py | 69 +++++++++ src/uas_standards/en4709_02.py | 181 ++++++++++++++++++++++++ tests/test_ansi_cta_2063_a.py | 21 +++ tests/test_en4709_02.py | 43 ++++++ 14 files changed, 821 insertions(+) create mode 100644 .github/workflows/publish.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .github/workflows/test_publish.yaml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/uas_standards/__init__.py create mode 100644 src/uas_standards/ansi_cta_2063_a.py create mode 100644 src/uas_standards/en4709_02.py create mode 100644 tests/test_ansi_cta_2063_a.py create mode 100644 tests/test_en4709_02.py diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..243590c --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,37 @@ +# Based on https://github.com/denkiwakame/py-tiny-pkg/blob/main/.github/workflows/pub.yml +# To create a release, see https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository + +name: publish + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: setup-python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + architecture: "x64" + - name: install pypa/build + run: >- + python -m + pip install + build + --user + - name: build sdist(tarball) and bdist(wheel) to dist/ + run: >- # = python -m build . works the same way by default + python -m + build + --sdist + --wheel + --outdir dist/ + - name: publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..2fdd7dc --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,55 @@ +# Based on https://github.com/denkiwakame/py-tiny-pkg/blob/main/.github/workflows/test.yml + +name: package + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + install-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.8", "3.9"] + max-parallel: 3 + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v3 + - name: setup-python + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + architecture: "x64" + - name: confirm pip version + run: pip --version + - name: installation + run: pip install .[dev] + - name: test + run: python -m pytest --cov + editable-install-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.8"] + max-parallel: 3 + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v3 + - name: setup-python + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + architecture: "x64" + - name: confirm pip version + run: pip --version + - name: installation + run: pip install -e .[dev] + - name: test + run: python -m pytest --cov diff --git a/.github/workflows/test_publish.yaml b/.github/workflows/test_publish.yaml new file mode 100644 index 0000000..c788e75 --- /dev/null +++ b/.github/workflows/test_publish.yaml @@ -0,0 +1,38 @@ +# Based on https://github.com/denkiwakame/py-tiny-pkg/blob/main/.github/workflows/testpub.yml + +name: publish-test + +on: + push: + tags: + - "*" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: setup-python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + architecture: "x64" + - name: install pypa/build + run: >- + python -m + pip install + build + --user + - name: build sdist(tarball) and bdist(wheel) to dist/ + run: >- # = python -m build . works the same way by default + python -m + build + --sdist + --wheel + --outdir dist/ + - name: publish to TestPyPI + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..c0c219b --- /dev/null +++ b/.gitignore @@ -0,0 +1,124 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b1f752e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +Contributions to this repository are managed via the same process as for the [dss repository](https://github.com/interuss/dss/CONTRIBUTING.md). diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..427417b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6dfaab --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# uas_standards + +This library primarily provides data types and tools for working with standards related to uncrewed aircraft systems (UAS). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4d3d47c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +# Based on https://github.com/denkiwakame/py-tiny-pkg/blob/main/pyproject.toml + +[build-system] +requires = [ + "setuptools>=64", + "wheel", # for bdist package distribution + "setuptools_scm>=6.4", # for automated versioning +] + +build-backend = "setuptools.build_meta" + +[tool.setuptools] +include-package-data = true +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = true + +[tool.setuptools_scm] +write_to = "src/uas_standards/_version.py" + +[project] +name = "uas_standards" +dynamic = ["version"] +authors = [ + { name="InterUSS Platform", email="tsc@lists.interussplatform.org" }, +] +description = "Data types and tools for working with standards related to uncrewed aircraft systems (UAS)" +readme = "README.md" +license = { file = "LICENSE.md" } +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] +dependencies = [] +[project.optional-dependencies] +dev = ["pytest==5.0.0", "pytest-cov[all]", "black==21.10b0"] +[project.urls] +"Homepage" = "https://github.com/interuss/uas_standards" +"Bug Tracker" = "https://github.com/interuss/uas_standards/issues" + +[tool.black] +target-version = ['py39'] +line-length = 120 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/uas_standards/__init__.py b/src/uas_standards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/uas_standards/ansi_cta_2063_a.py b/src/uas_standards/ansi_cta_2063_a.py new file mode 100644 index 0000000..0fee7d6 --- /dev/null +++ b/src/uas_standards/ansi_cta_2063_a.py @@ -0,0 +1,69 @@ +from __future__ import annotations +import random + + +class SerialNumber(str): + """Represents a serial number expressed in the ANSI/CTA-2063-A Physical Serial Number format.""" + + length_code_points = "123456789ABCDEF" + code_points = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ" + + @property + def manufacturer_code(self) -> str: + return self[0:4] + + @property + def length_code(self) -> str: + return self[4:5] + + @property + def manufacturer_serial_number(self) -> str: + return self[5:] + + @property + def valid(self) -> bool: + if len(self) < 6: + return False + if not all(c in SerialNumber.code_points for c in self.manufacturer_code): + return False + if self.length_code not in SerialNumber.length_code_points: + return False + manufacturer_serial_number_length = ( + SerialNumber.length_code_points.index(self.length_code) + 1 + ) + if manufacturer_serial_number_length != len(self.manufacturer_serial_number): + return False + return True + + def make_invalid_by_changing_payload_length(self) -> SerialNumber: + """Generates an invalid serial number similar to this serial number.""" + my_length = self.length_code + lengths_except_mine = [ + c for c in SerialNumber.length_code_points if c != my_length + ] + new_length_code = random.choice(lengths_except_mine) + k = SerialNumber.length_code_points.index(new_length_code) + 1 + random_serial_number = "".join(random.choices(SerialNumber.code_points, k=k)) + return SerialNumber( + self.manufacturer_code + self.length_code + random_serial_number + ) + + @staticmethod + def from_components( + manufacturer_code: str, manufacturer_serial_number: str + ) -> SerialNumber: + """Constructs a standard serial number from the provided components""" + length_code = SerialNumber.length_code_points[ + len(manufacturer_serial_number) - 1 + ] + return SerialNumber( + manufacturer_code + length_code + manufacturer_serial_number + ) + + @staticmethod + def generate_valid() -> SerialNumber: + """Generates a valid and random UAV serial number per ANSI/CTA-2063-A.""" + manufacturer_code = "".join(random.choices(SerialNumber.code_points, k=4)) + k = random.randrange(0, len(SerialNumber.length_code_points)) + 1 + random_serial_number = "".join(random.choices(SerialNumber.code_points, k=k)) + return SerialNumber.from_components(manufacturer_code, random_serial_number) diff --git a/src/uas_standards/en4709_02.py b/src/uas_standards/en4709_02.py new file mode 100644 index 0000000..38ecbd6 --- /dev/null +++ b/src/uas_standards/en4709_02.py @@ -0,0 +1,181 @@ +from __future__ import annotations +import random +import string + + +class OperatorRegistrationNumber(str): + """Represents an operator registration number as formatted according to the EN4709-02 standard.""" + + registration_number_code_points = "0123456789abcdefghijklmnopqrstuvwxyz" + prefix_length = 3 + base_id_length = 12 + final_random_string_length = 3 + checksum_length = 1 + public_number_length = prefix_length + base_id_length + checksum_length + dash_length = 1 + full_number_length = public_number_length + dash_length + final_random_string_length + + @property + def checksum_control(self) -> str: + return self.split("-")[0] + + @property + def prefix(self) -> str: + return self[0 : OperatorRegistrationNumber.prefix_length] + + @property + def base_id(self) -> str: + return self[ + OperatorRegistrationNumber.prefix_length : OperatorRegistrationNumber.prefix_length + + OperatorRegistrationNumber.base_id_length + ] + + @property + def checksum(self) -> str: + return self[ + OperatorRegistrationNumber.prefix_length + + OperatorRegistrationNumber.base_id_length : + ][0] + + @property + def final_random_string(self): + return self[-OperatorRegistrationNumber.final_random_string_length :] + + @property + def valid(self) -> bool: + # PPPBBBBBBBBBBBBC-FFF + # P = prefix, B = base ID, C = checksum, F = final random string + if len(self) != OperatorRegistrationNumber.full_number_length: + return False + if self[OperatorRegistrationNumber.public_number_length] != "-": + return False + if not all( + c in OperatorRegistrationNumber.registration_number_code_points + for c in self.base_id + ): + return False + if not all( + c in OperatorRegistrationNumber.registration_number_code_points + for c in self.final_random_string + ): + return False + checksum = OperatorRegistrationNumber.generate_checksum( + self.base_id, self.final_random_string + ) + return self.checksum == checksum + + def make_invalid_by_changing_final_control_string( + self, + ) -> OperatorRegistrationNumber: + """A method to generate an invalid Operator Registration number by replacing the control string""" + new_random_string = "".join( + random.choice(string.ascii_lowercase) + for _ in range(OperatorRegistrationNumber.final_random_string_length) + ) + return OperatorRegistrationNumber( + self.checksum_control + "-" + new_random_string + ) + + @staticmethod + def validate_prefix(prefix: str) -> None: + if len(prefix) != OperatorRegistrationNumber.prefix_length: + raise ValueError( + "Prefix of an operator registration number must be {} characters long rather than {}".format( + OperatorRegistrationNumber.prefix_length, len(prefix) + ) + ) + + @staticmethod + def validate_base_id(base_id: str) -> None: + if len(base_id) != OperatorRegistrationNumber.base_id_length: + raise ValueError( + "Base ID of an operator registration number must be {} characters long rather than {}".format( + OperatorRegistrationNumber.base_id_length, len(base_id) + ) + ) + if not all( + c in OperatorRegistrationNumber.registration_number_code_points + for c in base_id + ): + raise ValueError( + "Base ID of an operator registration number must be alphanumeric" + ) + + @staticmethod + def validate_final_random_string(final_random_string: str) -> None: + if ( + len(final_random_string) + != OperatorRegistrationNumber.final_random_string_length + ): + raise ValueError( + "Final random string of an operator registration number must be {} characters long rather than {}".format( + OperatorRegistrationNumber.final_random_string_length, + len(final_random_string), + ) + ) + if not all( + c in OperatorRegistrationNumber.registration_number_code_points + for c in final_random_string + ): + raise ValueError( + "Final random string of an operator registration number must be alphanumeric" + ) + + @staticmethod + def generate_checksum(base_id: str, final_random_string: str) -> str: + OperatorRegistrationNumber.validate_base_id(base_id) + OperatorRegistrationNumber.validate_final_random_string(final_random_string) + raw_id = base_id + final_random_string + + full_sum = 0 + multiplier = 2 + n = len(OperatorRegistrationNumber.registration_number_code_points) + for c in raw_id: + v = OperatorRegistrationNumber.registration_number_code_points.index(c) + quotient, remainder = divmod(v * multiplier, n) + full_sum += quotient + remainder + multiplier = 3 - multiplier + + control_number = -full_sum % n + return OperatorRegistrationNumber.registration_number_code_points[ + control_number + ] + + @staticmethod + def generate_valid(prefix: str) -> OperatorRegistrationNumber: + """Generate a random operator registration number with the specified prefix""" + final_random_string = "".join( + random.choice(string.ascii_lowercase) + for _ in range(OperatorRegistrationNumber.final_random_string_length) + ) + base_id = "".join( + random.choice(string.ascii_lowercase + string.digits) + for _ in range(OperatorRegistrationNumber.base_id_length) + ) + return OperatorRegistrationNumber.from_components( + prefix, base_id, final_random_string + ) + + @staticmethod + def from_components( + prefix: str, base_id: str, final_random_string: str + ) -> OperatorRegistrationNumber: + """Constructs a standard operator registration number from the provided components""" + OperatorRegistrationNumber.validate_prefix(prefix) + OperatorRegistrationNumber.validate_base_id(base_id) + if ( + len(final_random_string) + != OperatorRegistrationNumber.final_random_string_length + ): + raise ValueError( + "Prefix of an operator registration number must be {} characters long rather than {}".format( + OperatorRegistrationNumber.final_random_string_length, + len(final_random_string), + ) + ) + checksum = OperatorRegistrationNumber.generate_checksum( + base_id, final_random_string + ) + return OperatorRegistrationNumber( + prefix + base_id + checksum + "-" + final_random_string + ) diff --git a/tests/test_ansi_cta_2063_a.py b/tests/test_ansi_cta_2063_a.py new file mode 100644 index 0000000..28deb66 --- /dev/null +++ b/tests/test_ansi_cta_2063_a.py @@ -0,0 +1,21 @@ +import json + +from uas_standards.ansi_cta_2063_a import SerialNumber + + +def test_basic_usage(): + sn = SerialNumber.generate_valid() + assert sn.valid + + sn2 = SerialNumber.from_components(sn.manufacturer_code, sn.manufacturer_serial_number) + assert sn2.valid + assert sn2 == sn + + plain_str = json.loads(json.dumps({'sn': sn}))['sn'] + sn3 = SerialNumber(plain_str) + assert sn3.valid + assert sn3 == sn + + sn_invalid = sn.make_invalid_by_changing_payload_length() + assert sn.valid + assert not sn_invalid.valid diff --git a/tests/test_en4709_02.py b/tests/test_en4709_02.py new file mode 100644 index 0000000..6098ab5 --- /dev/null +++ b/tests/test_en4709_02.py @@ -0,0 +1,43 @@ +import json +import pytest + +from uas_standards.en4709_02 import OperatorRegistrationNumber + + +def test_basic_usage(): + rn = OperatorRegistrationNumber.generate_valid('EXM') + assert rn.valid + OperatorRegistrationNumber.validate_prefix(rn.prefix) + OperatorRegistrationNumber.validate_base_id(rn.base_id) + OperatorRegistrationNumber.validate_final_random_string(rn.final_random_string) + + rn2 = OperatorRegistrationNumber.from_components(rn.prefix, rn.base_id, rn.final_random_string) + assert rn2.valid + assert rn2 == rn + + plain_str = json.loads(json.dumps({'rn': rn}))['rn'] + rn3 = OperatorRegistrationNumber(plain_str) + assert rn3.valid + assert rn3 == rn + + rn_invalid = rn.make_invalid_by_changing_final_control_string() + assert rn.valid + assert not rn_invalid.valid + OperatorRegistrationNumber.validate_prefix(rn_invalid.prefix) + OperatorRegistrationNumber.validate_base_id(rn_invalid.base_id) + OperatorRegistrationNumber.validate_final_random_string(rn_invalid.final_random_string) + + with pytest.raises(ValueError): + OperatorRegistrationNumber.validate_prefix('US') + + OperatorRegistrationNumber.validate_base_id('aaaaaaaaaaaa') + with pytest.raises(ValueError): + OperatorRegistrationNumber.validate_base_id('aaaaaaaaaaa') + with pytest.raises(ValueError): + OperatorRegistrationNumber.validate_base_id('aaaaaaaaaaaA') + + OperatorRegistrationNumber.validate_final_random_string('aaa') + with pytest.raises(ValueError): + OperatorRegistrationNumber.validate_final_random_string('aa') + with pytest.raises(ValueError): + OperatorRegistrationNumber.validate_final_random_string('aaA')