init version

This commit is contained in:
Xuwznln
2025-09-02 16:39:44 +08:00
commit 94f0c112e5
41 changed files with 6004 additions and 0 deletions

10
.bumpversion.cfg Normal file
View File

@@ -0,0 +1,10 @@
[bumpversion]
current_version = 0.0.2
commit = True
tag = True
tag_name = v{new_version}
message = Bump version: {current_version} → {new_version}
[bumpversion:file:msgcenterpy/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"

37
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
version: 2
updates:
# Python dependencies
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 10
reviewers:
- "msgcenterpy-team"
assignees:
- "msgcenterpy-team"
labels:
- "dependencies"
- "python"
commit-message:
prefix: "deps"
include: "scope"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 5
reviewers:
- "msgcenterpy-team"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"
include: "scope"

224
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,224 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Python package
on:
push:
branches: ["main", "dev"]
pull_request:
branches: ["main", "dev"]
jobs:
# Step 1: Code formatting and pre-commit validation (fast failure)
code-format:
name: Code formatting and pre-commit validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10" # Use minimum version for consistency
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
- name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1
with:
extra_args: --all-files
# Step 2: Basic build and test with minimum Python version (3.10)
basic-build:
name: Basic build (Python 3.10, Ubuntu)
runs-on: ubuntu-latest
needs: [code-format] # Only run after code formatting passes
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ubuntu-pip-3.10-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
ubuntu-pip-3.10-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
pip install -e .[dev]
- name: Test with pytest
run: |
pytest
# Step 3: ROS2 integration test
test-with-ros2:
name: ROS2 integration test
runs-on: ubuntu-latest
needs: [basic-build] # Only run after basic build passes
steps:
- uses: actions/checkout@v4
- name: Setup Miniconda
uses: conda-incubator/setup-miniconda@v3
with:
miniconda-version: "latest"
channels: conda-forge,robostack-staging,defaults
channel-priority: strict
activate-environment: ros2-test-env
python-version: "3.11.11"
auto-activate-base: false
auto-update-conda: false
show-channel-urls: true
- name: Install ROS2 and dependencies
shell: bash -l {0}
run: |
# Install ROS2 core packages
conda install -y \
ros-humble-ros-core \
ros-humble-std-msgs \
ros-humble-geometry-msgs
- name: Install package and run tests
shell: bash -l {0}
run: |
# Install our package with basic dependencies (not ros2 extra to avoid conflicts)
pip install -e .[dev]
# Run all tests with verbose output (ROS2 tests will be automatically included)
python -c "import rclpy, rosidl_runtime_py; print('All ROS2 dependencies available')"
pytest -v
# Step 4: Security scan
security:
name: Security scan
runs-on: ubuntu-latest
needs: [basic-build] # Run in parallel with ROS2 test after basic build
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10" # Use minimum version for consistency
- name: Install security tools
run: |
python -m pip install --upgrade pip
pip install bandit "safety>=3.0.0" "typer<0.12.0" "marshmallow<4.0.0"
- name: Run bandit security scan
run: bandit -r msgcenterpy/ -f json -o bandit-report.json
- name: Run safety security scan
run: safety check --output json > safety-report.json
- name: Upload security reports
uses: actions/upload-artifact@v4
with:
name: security-reports
path: |
bandit-report.json
safety-report.json
if: always()
# Step 5: Package build check
package-build:
name: Package build check
runs-on: ubuntu-latest
needs: [basic-build] # Run in parallel with other checks
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10" # Use minimum version for consistency
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# Step 6: Full matrix build (only after all basic checks pass)
full-matrix-build:
name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
needs: [test-with-ros2, security, package-build] # Wait for all prerequisite checks
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
exclude:
# Skip the combination we already tested in basic-build
- os: ubuntu-latest
python-version: "3.10"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
pip install -e .[dev]
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-line-length=200 --extend-ignore=E203,W503,F401,E402,E721,F841 --statistics
- name: Type checking with mypy
run: |
mypy msgcenterpy --disable-error-code=unused-ignore
- name: Test with pytest
run: |
pytest

69
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Build and Deploy Documentation
on:
push:
branches: [main]
pull_request:
branches: [main]
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build documentation
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Install package in development mode to get version info
pip install -e .
# Install documentation dependencies
pip install -r docs/requirements.txt
- name: Setup Pages
id: pages
uses: actions/configure-pages@v4
if: github.ref == 'refs/heads/main'
- name: Build Sphinx documentation
run: |
cd docs
make html
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
if: github.ref == 'refs/heads/main'
with:
path: docs/_build/html
# Deploy to GitHub Pages (only on main branch)
deploy:
if: github.ref == 'refs/heads/main'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

258
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,258 @@
# This workflow will upload a Python Package to PyPI 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
on:
release:
types: [published]
workflow_dispatch:
inputs:
test_pypi:
description: "Publish to Test PyPI instead of PyPI"
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
code-format:
name: Code formatting and pre-commit validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
- name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1
with:
extra_args: --all-files
basic-build:
name: Basic build and test
runs-on: ubuntu-latest
needs: [code-format]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ubuntu-pip-3.10-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
ubuntu-pip-3.10-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
pip install -e .[dev]
- name: Test with pytest
run: |
pytest -v
- name: Run linting
run: |
black --check --line-length=120 msgcenterpy tests
isort --check-only msgcenterpy tests
mypy msgcenterpy --disable-error-code=unused-ignore
test-with-ros2:
name: ROS2 integration test
runs-on: ubuntu-latest
needs: [basic-build]
steps:
- uses: actions/checkout@v4
- name: Setup Miniconda
uses: conda-incubator/setup-miniconda@v3
with:
miniconda-version: "latest"
channels: conda-forge,robostack-staging,defaults
channel-priority: strict
activate-environment: ros2-test-env
python-version: "3.11.11"
auto-activate-base: false
auto-update-conda: false
show-channel-urls: true
- name: Install ROS2 and dependencies
shell: bash -l {0}
run: |
conda install -y \
ros-humble-ros-core \
ros-humble-std-msgs \
ros-humble-geometry-msgs
- name: Install package and run tests
shell: bash -l {0}
run: |
pip install -e .[dev]
python -c "import rclpy, rosidl_runtime_py; print('All ROS2 dependencies available')"
pytest -v
release-build:
name: Build release distributions
runs-on: ubuntu-latest
needs: [test-with-ros2]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
python -m pip install build twine check-manifest
- name: Verify version consistency
if: github.event_name == 'release'
run: |
VERSION=$(python -c "import msgcenterpy; print(msgcenterpy.__version__)" 2>/dev/null || echo "unknown")
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
if [ "$VERSION" != "$TAG_VERSION" ]; then
echo "Version mismatch: package=$VERSION, tag=$TAG_VERSION"
exit 1
fi
- name: Check manifest
run: check-manifest
- name: Build release distributions
run: |
python -m build
- name: Check package
run: |
twine check dist/*
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: release-dists
path: dist/
pypi-publish:
name: Publish to PyPI
runs-on: ubuntu-latest
needs:
- release-build
if: github.event_name == 'release' && !github.event.release.prerelease && github.event.inputs.test_pypi != 'true'
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
# Note: For enhanced security, consider configuring deployment environments
# in your GitHub repository settings with protection rules
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
test-pypi-publish:
name: Publish to Test PyPI
runs-on: ubuntu-latest
needs:
- release-build
if: github.event.inputs.test_pypi == 'true' || (github.event_name == 'release' && github.event.release.prerelease)
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
# Note: For enhanced security, consider configuring deployment environments
# in your GitHub repository settings with protection rules
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Publish release distributions to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
create-github-release-assets:
name: Add assets to GitHub release
runs-on: ubuntu-latest
needs: release-build
if: github.event_name == 'release'
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Upload release assets
uses: softprops/action-gh-release@v1
with:
files: dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
post-publish:
name: Post-publish tasks
runs-on: ubuntu-latest
needs: [pypi-publish, test-pypi-publish]
if: always() && (needs.pypi-publish.result == 'success' || needs.test-pypi-publish.result == 'success')
steps:
- uses: actions/checkout@v4
- name: Create deployment summary
run: |
echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "| Item | Status |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.pypi-publish.result }}" = "success" ]; then
echo "| PyPI | Published |" >> $GITHUB_STEP_SUMMARY
elif [ "${{ needs.test-pypi-publish.result }}" = "success" ]; then
echo "| Test PyPI | Published |" >> $GITHUB_STEP_SUMMARY
fi
echo "| GitHub Release | Assets uploaded |" >> $GITHUB_STEP_SUMMARY
echo "| Version | ${{ github.event.release.tag_name || 'test' }} |" >> $GITHUB_STEP_SUMMARY
- name: Notify team
run: |
echo "Package published successfully!"

57
.gitignore vendored Normal file
View File

@@ -0,0 +1,57 @@
# ================================
# Python-related files
# ================================
# Compiled Python files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
.Python
build/
dist/
*.egg-info/
.installed.cfg
*.egg
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Type checking
.mypy_cache/
# Documentation
docs/_build/
docs/_static/
docs/_templates/
docs/_static/
# ================================
# IDE and Editor files
# ================================
# PyCharm
.idea/
# Visual Studio Code
.vscode/
# ================================
# Operating System files
# ================================
# macOS
.DS_Store

81
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,81 @@
repos:
# Code formatting
- repo: https://github.com/psf/black
rev: 23.12.1
hooks:
- id: black
language_version: python3
args: ["--line-length=120"]
# Import sorting
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: ["--profile", "black", "--multi-line", "3"]
# Linting
- repo: https://github.com/pycqa/flake8
rev: 7.0.0
hooks:
- id: flake8
args:
- "--max-line-length=200" # Allow longer lines after black formatting
- "--extend-ignore=E203,W503,F401,E402,E721,F841"
- "--exclude=build,dist,__pycache__,.mypy_cache,.pytest_cache,htmlcov,.idea,.vscode,docs/_build,msgcenterpy.egg-info"
# Type checking
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-PyYAML, types-jsonschema, pydantic]
args: ["--ignore-missing-imports", "--disable-error-code=unused-ignore"]
files: "^(msgcenterpy/)" # Check both source code and tests
# General pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
# File checks
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-xml
# Security
- id: check-merge-conflict
- id: check-case-conflict
- id: check-symlinks
- id: check-added-large-files
args: ["--maxkb=1000"]
# Python specific
- id: check-ast
- id: debug-statements
- id: name-tests-test
args: ["--django"]
# Security scanning
- repo: https://github.com/PyCQA/bandit
rev: 1.7.5
hooks:
- id: bandit
args: ["-c", "pyproject.toml"]
additional_dependencies: ["bandit[toml]", "pbr"]
exclude: "^tests/"
# YAML/JSON formatting
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
types_or: [yaml, json, markdown]
exclude: "^(build/|dist/|__pycache__/|\\.mypy_cache/|\\.pytest_cache/|htmlcov/|\\.idea/|\\.vscode/|docs/_build/|msgcenterpy\\.egg-info/)"
# Global settings
default_stages: [pre-commit, pre-push]
fail_fast: false

199
LICENSE Normal file
View File

@@ -0,0 +1,199 @@
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 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
(which shall not include communications that are merely
incorporated by reference without repeating their full text).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based upon (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 use, reproduce, modify, display, 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, trademark, patent,
attribution and other 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 notice 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 Support. You may choose to offer and charge
a fee for, warranty, support, 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 support.
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.

6
MANIFEST.in Normal file
View File

@@ -0,0 +1,6 @@
# Include important project files
include LICENSE
include README.md
# Include all msgcenterpy source files
recursive-include msgcenterpy *

236
README.md Normal file
View File

@@ -0,0 +1,236 @@
# MsgCenterPy
<div align="center">
[![PyPI version](https://badge.fury.io/py/msgcenterpy.svg)](https://badge.fury.io/py/msgcenterpy)
[![Python versions](https://img.shields.io/pypi/pyversions/msgcenterpy.svg)](https://pypi.org/project/msgcenterpy/)
[![Build Status](https://github.com/ZGCA-Forge/MsgCenterPy/workflows/CI/badge.svg)](https://github.com/ZGCA-Forge/MsgCenterPy/actions)
[![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-brightgreen)](https://zgca-forge.github.io/MsgCenterPy/)
[![GitHub stars](https://img.shields.io/github/stars/ZGCA-Forge/MsgCenterPy.svg?style=social&label=Star)](https://github.com/ZGCA-Forge/MsgCenterPy)
[![GitHub forks](https://img.shields.io/github/forks/ZGCA-Forge/MsgCenterPy.svg?style=social&label=Fork)](https://github.com/ZGCA-Forge/MsgCenterPy/fork)
[![GitHub issues](https://img.shields.io/github/issues/ZGCA-Forge/MsgCenterPy.svg)](https://github.com/ZGCA-Forge/MsgCenterPy/issues)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/ZGCA-Forge/MsgCenterPy/blob/main/LICENSE)
</div>
---
## 🚀 概述
MsgCenterPy 是一个基于统一实例管理器架构的多格式消息转换系统,支持 **ROS2**、**Pydantic**、**Dataclass**、**JSON**、**Dict**、**YAML** 和 **JSON Schema** 之间的无缝互转。
### 支持的格式
| 格式 | 读取 | 写入 | JSON Schema | 类型约束 |
| ----------- | ------ | ------ | ----------- | -------- |
| ROS2 | ✅ | ✅ | ✅ | ✅ |
| JSON Schema | ✅ | ✅ | ✅ | ✅ |
| Pydantic | 开发中 | 开发中 | 开发中 | 开发中 |
| Dataclass | 开发中 | 开发中 | 开发中 | 开发中 |
| JSON | 开发中 | 开发中 | 开发中 | 开发中 |
| Dict | 开发中 | 开发中 | 开发中 | 开发中 |
| YAML | 开发中 | 开发中 | 开发中 | 开发中 |
## 📦 安装
### 基础安装
```bash
pip install msgcenterpy
```
### 包含可选依赖
```bash
# 安装 ROS2 支持
conda install
# 安装开发工具
pip install msgcenterpy[dev]
# 安装文档工具
pip install msgcenterpy[docs]
# 安装所有依赖
pip install msgcenterpy[all]
```
### 从源码安装
```bash
git clone https://github.com/ZGCA-Forge/MsgCenterPy.git
cd MsgCenterPy
pip install -e .[dev]
```
## 🔧 快速开始
### 基础使用
```python
from msgcenterpy import MessageInstance, MessageType
# 从字典创建消息实例
data = {
"name": "sensor_001",
"readings": [1.0, 2.0, 3.0],
"active": True
}
dict_instance = MessageInstance.create(MessageType.DICT, data)
# 生成 JSON Schema
schema = dict_instance.get_json_schema()
print(schema)
```
### ROS2 消息转换
```python
from std_msgs.msg import String
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
# 创建 ROS2 消息实例
string_msg = String()
string_msg.data = "Hello ROS2"
ros2_instance = ROS2MessageInstance(string_msg)
# 转换为 JSON Schema
json_schema_instance = ros2_instance.to_json_schema()
# 获取生成的 Schema
schema = json_schema_instance.json_schema
print(schema)
```
### 字段访问和约束
```python
# 动态字段访问
ros2_instance.data_field = "new_value"
print(ros2_instance.fields.get_field_info("data"))
# 类型约束验证
type_info = ros2_instance.fields.get_sub_type_info("data")
print(f"约束条件: {[c.type.value for c in type_info.constraints]}")
```
## 📖 文档
### 核心概念
- **MessageInstance**: 统一消息实例基类
- **TypeInfo**: 详细的字段类型信息和约束
- **FieldAccessor**: 统一字段访问接口
- **MessageCenter**: 消息类型管理和转换中心
### API 参考
详细的 API 文档请访问:[https://zgca-forge.github.io/MsgCenterPy/](https://zgca-forge.github.io/MsgCenterPy/)
### 示例代码
更多示例请查看 [`examples/`](examples/) 目录:
- [ROS2 消息转换示例](examples/ros2_example.py)
- [JSON Schema 生成示例](examples/json_schema_example.py)
- [类型约束示例](examples/type_constraints_example.py)
## 🧪 测试
### 运行测试
```bash
# 运行所有测试
python -m pytest
# 运行特定测试套件
python run_all_tests.py --type json_schema
```
```bash
# 生成覆盖率报告
pytest --cov=msgcenterpy --cov-report=html
```
## 🛠️ 开发
### 开发环境设置
```bash
git clone https://github.com/ZGCA-Forge/MsgCenterPy.git
cd MsgCenterPy
pip install -e .[dev]
pre-commit install
```
### 代码质量
```bash
# 代码格式化
black msgcenterpy tests
isort msgcenterpy tests
# 类型检查
mypy msgcenterpy
# 运行测试
pytest
```
### 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交变更 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
详细贡献指南请查看 [CONTRIBUTING.md](CONTRIBUTING.md)
## 📊 开发路线图
- [x] ✅ ROS2 消息支持
- [x] ✅ JSON Schema 生成和验证
- [x] ✅ 统一字段访问器
- [x] ✅ 类型约束系统
- [ ] 🔄 Pydantic 集成
- [ ] 🔄 Dataclass 支持
- [ ] 🔄 YAML 格式支持
- [ ] 🔄 性能优化
- [ ] 🔄 插件系统
## 🤝 社区
### 支持渠道
- 💬 讨论: [GitHub Discussions](https://github.com/ZGCA-Forge/MsgCenterPy/discussions)
- 🐛 问题: [GitHub Issues](https://github.com/ZGCA-Forge/MsgCenterPy/issues)
- 📖 文档: [GitHub Pages](https://zgca-forge.github.io/MsgCenterPy/)
### 贡献者
感谢所有为 MsgCenterPy 做出贡献的开发者!
[![Contributors](https://contrib.rocks/image?repo=ZGCA-Forge/MsgCenterPy)](https://github.com/ZGCA-Forge/MsgCenterPy/graphs/contributors)
## 📄 许可证
本项目基于 Apache-2.0 许可证开源 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 🙏 致谢
- [ROS2](https://ros.org/) - 机器人操作系统
- [Pydantic](https://pydantic-docs.helpmanual.io/) - 数据验证库
- [pytest](https://pytest.org/) - 测试框架
- [jsonschema](https://python-jsonschema.readthedocs.io/) - JSON Schema 验证
---
<div align="center">
**[⭐ 给个 Star](https://github.com/ZGCA-Forge/MsgCenterPy)** • **[🍴 Fork 项目](https://github.com/ZGCA-Forge/MsgCenterPy/fork)** • **[📖 查看文档](https://zgca-forge.github.io/MsgCenterPy/)**
Made with ❤️ by the MsgCenterPy Team
</div>

64
docs/Makefile Normal file
View File

@@ -0,0 +1,64 @@
# 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 = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.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)
# Custom targets for common operations
clean:
@echo "Cleaning build directory..."
rm -rf $(BUILDDIR)/*
html:
@echo "Building HTML documentation..."
@$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O)
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
livehtml:
@echo "Starting live HTML build..."
sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O)
linkcheck:
@echo "Checking external links..."
@$(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(BUILDDIR)/linkcheck" $(SPHINXOPTS) $(O)
@echo
@echo "Link check complete; look for any errors in the above output."
doctest:
@echo "Running doctests..."
@$(SPHINXBUILD) -b doctest "$(SOURCEDIR)" "$(BUILDDIR)/doctest" $(SPHINXOPTS) $(O)
@echo "doctest finished; look at the results in $(BUILDDIR)/doctest."
coverage:
@echo "Checking documentation coverage..."
@$(SPHINXBUILD) -b coverage "$(SOURCEDIR)" "$(BUILDDIR)/coverage" $(SPHINXOPTS) $(O)
@echo "Coverage finished; see $(BUILDDIR)/coverage/python.txt."
latexpdf:
@echo "Building LaTeX files and running them through pdflatex..."
@$(SPHINXBUILD) -b latex "$(SOURCEDIR)" "$(BUILDDIR)/latex" $(SPHINXOPTS) $(O)
@echo "Running LaTeX files through pdflatex..."
@make -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
epub:
@echo "Building EPUB documentation..."
@$(SPHINXBUILD) -b epub "$(SOURCEDIR)" "$(BUILDDIR)/epub" $(SPHINXOPTS) $(O)
@echo
@echo "Build finished. The EPUB file is in $(BUILDDIR)/epub."

60
docs/api/core.rst Normal file
View File

@@ -0,0 +1,60 @@
Core API Reference
==================
This section documents the core components of MsgCenterPy.
MessageInstance
---------------
.. autoclass:: msgcenterpy.core.message_instance.MessageInstance
:members:
:undoc-members:
:show-inheritance:
MessageType
-----------
.. autoclass:: msgcenterpy.core.types.MessageType
:members:
:undoc-members:
:show-inheritance:
TypeInfo
--------
.. autoclass:: msgcenterpy.core.type_info.TypeInfo
:members:
:undoc-members:
:show-inheritance:
FieldAccessor
-------------
.. autoclass:: msgcenterpy.core.field_accessor.FieldAccessor
:members:
:undoc-members:
:show-inheritance:
MessageCenter
-------------
.. autoclass:: msgcenterpy.core.message_center.MessageCenter
:members:
:undoc-members:
:show-inheritance:
TypeConverter
-------------
.. autoclass:: msgcenterpy.core.type_converter.TypeConverter
:members:
:undoc-members:
:show-inheritance:
Envelopes
---------
.. automodule:: msgcenterpy.core.envelope
:members:
:undoc-members:
:show-inheritance:

135
docs/conf.py Normal file
View File

@@ -0,0 +1,135 @@
"""Configuration file for the Sphinx documentation builder.
For the full list of built-in configuration values, see the documentation:
https://www.sphinx-doc.org/en/master/usage/configuration.html
"""
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import os
import sys
sys.path.insert(0, os.path.abspath(".."))
# Import version from package
try:
from msgcenterpy import __version__
version = __version__
except ImportError:
version = "0.0.1" # fallback
project = "MsgCenterPy"
copyright = "2025, MsgCenterPy Team"
author = "MsgCenterPy Team"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinx.ext.intersphinx",
"sphinx.ext.todo",
"sphinx.ext.coverage",
"sphinx.ext.mathjax",
"myst_parser",
]
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
language = "en"
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
# -- Extension configuration -------------------------------------------------
# Napoleon settings
napoleon_google_docstring = True
napoleon_numpy_docstring = True
napoleon_include_init_with_doc = False
napoleon_include_private_with_doc = False
napoleon_include_special_with_doc = True
napoleon_use_admonition_for_examples = False
napoleon_use_admonition_for_notes = False
napoleon_use_admonition_for_references = False
napoleon_use_ivar = False
napoleon_use_param = True
napoleon_use_rtype = True
# Autodoc settings
autodoc_default_options = {
"members": True,
"member-order": "bysource",
"special-members": "__init__",
"undoc-members": True,
"exclude-members": "__weakref__",
}
# Intersphinx mapping
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"numpy": ("https://numpy.org/doc/stable", None),
"pydantic": ("https://docs.pydantic.dev", None),
}
# MyST parser settings
myst_enable_extensions = [
"deflist",
"tasklist",
"dollarmath",
"amsmath",
"colon_fence",
"attrs_inline",
]
# Todo settings
todo_include_todos = True
# HTML theme options
html_theme_options = {
"canonical_url": "",
"analytics_id": "", # Provided by Google in your dashboard
"logo_only": False,
"display_version": True,
"prev_next_buttons_location": "bottom",
"style_external_links": False,
"vcs_pageview_mode": "",
"style_nav_header_background": "#2980B9",
# Toc options
"collapse_navigation": True,
"sticky_navigation": True,
"navigation_depth": 4,
"includehidden": True,
"titles_only": False,
}
# Custom sidebar
html_sidebars = {
"**": [
"about.html",
"navigation.html",
"relations.html",
"searchbox.html",
"donate.html",
]
}
# -- Custom CSS and JS -------------------------------------------------------
html_css_files = ["custom.css"]
# GitHub URL
html_context = {
"display_github": True, # Integrate GitHub
"github_user": "ZGCA-Forge", # Username
"github_repo": "MsgCenterPy", # Repo name
"github_version": "main", # Version
"conf_py_path": "/docs/", # Path in the checkout to the docs root
}

View File

@@ -0,0 +1,160 @@
Contributing to MsgCenterPy
===========================
Thank you for your interest in contributing to MsgCenterPy! This document provides guidelines for contributing to the project.
For detailed contribution guidelines, please see our `Contributing Guide <https://github.com/ZGCA-Forge/MsgCenterPy/blob/main/.github/CONTRIBUTING.md>`_ on GitHub.
Quick Links
-----------
- `GitHub Repository <https://github.com/ZGCA-Forge/MsgCenterPy>`_
- `Issue Tracker <https://github.com/ZGCA-Forge/MsgCenterPy/issues>`_
- `Pull Requests <https://github.com/ZGCA-Forge/MsgCenterPy/pulls>`_
- `Discussions <https://github.com/ZGCA-Forge/MsgCenterPy/discussions>`_
Development Setup
-----------------
1. Fork the repository on GitHub
2. Clone your fork locally
3. Install in development mode:
.. code-block:: bash
git clone https://github.com/YOUR-USERNAME/MsgCenterPy.git
cd MsgCenterPy
pip install -e .[dev]
4. Set up pre-commit hooks:
.. code-block:: bash
pre-commit install
Code Style and Quality
----------------------
We use several tools to maintain code quality:
- **Black**: Code formatting
- **isort**: Import sorting
- **mypy**: Type checking
- **pytest**: Testing
Run quality checks:
.. code-block:: bash
# Format code
black msgcenterpy tests
isort msgcenterpy tests
# Type checking
mypy msgcenterpy
# Run tests
pytest
Testing
-------
Please ensure all tests pass and add tests for new features:
.. code-block:: bash
# Run all tests
pytest
# Run with coverage
pytest -v
# Run specific test file
pytest tests/test_specific_module.py
Version Management
------------------
This project uses `bump2version` for semantic version management. The tool is included in development dependencies and automatically manages version numbers across the codebase.
**Setup**
bump2version is automatically installed when you install development dependencies:
.. code-block:: bash
pip install -e .[dev]
**Configuration**
Version configuration is stored in `.bumpversion.cfg`:
- **Single source of truth**: `msgcenterpy/__init__.py`
- **Auto-commit**: Creates commit with version bump
- **Auto-tag**: Creates git tag for new version
- **Semantic versioning**: Follows MAJOR.MINOR.PATCH format
**Usage**
.. code-block:: bash
# Bug fixes (0.0.1 → 0.0.2)
bump2version patch
# New features (0.0.2 → 0.1.0)
bump2version minor
# Breaking changes (0.1.0 → 1.0.0)
bump2version major
**Release Workflow**
1. Make your changes and commit them
2. Choose appropriate version bump type
3. Run bump2version command
4. Push changes and tags:
.. code-block:: bash
git push && git push --tags
**Version Bump Guidelines**
- **patch**: Bug fixes, documentation updates, internal refactoring
- **minor**: New features, backward-compatible API additions
- **major**: Breaking changes, API removals or modifications
**Notes**
- Only developers need bump2version (it's in dev dependencies only)
- Version numbers are automatically synchronized across all files
- Git working directory must be clean before version bump
- Each version bump creates a commit and git tag automatically
Submitting Changes
------------------
1. Create a new branch for your feature/fix
2. Make your changes
3. Add tests for new functionality
4. Ensure all tests pass
5. Update documentation if needed
6. Submit a pull request
Pull Request Guidelines
-----------------------
- Use descriptive titles and descriptions
- Reference related issues
- Include tests for new features
- Update documentation as needed
- Follow the existing code style
Getting Help
------------
If you need help:
- Check existing `Issues <https://github.com/ZGCA-Forge/MsgCenterPy/issues>`_
- Start a `Discussion <https://github.com/ZGCA-Forge/MsgCenterPy/discussions>`_
- Contact the maintainers

View File

@@ -0,0 +1,225 @@
Basic Usage Examples
====================
This page contains basic usage examples to help you get started with MsgCenterPy.
Creating Message Instances
---------------------------
Dictionary Messages
~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from msgcenterpy import MessageInstance, MessageType
# Simple dictionary
simple_data = {"name": "sensor_01", "active": True}
instance = MessageInstance.create(MessageType.DICT, simple_data)
# Access fields
name = instance.get_field("name")
print(f"Sensor name: {name}")
# Nested dictionary
nested_data = {
"device": {
"id": "dev_001",
"sensors": [
{"type": "temperature", "value": 23.5},
{"type": "humidity", "value": 65.2}
]
}
}
nested_instance = MessageInstance.create(MessageType.DICT, nested_data)
JSON Schema Generation
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# Generate JSON Schema from dictionary
schema = instance.get_json_schema()
print("Generated Schema:")
print(schema)
# Schema includes type information
assert schema["type"] == "object"
assert "name" in schema["properties"]
assert schema["properties"]["name"]["type"] == "string"
Field Access and Manipulation
------------------------------
Getting Field Values
~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# Simple field access
name = instance.get_field("name")
# Nested field access using dot notation
device_id = nested_instance.get_field("device.id")
temp_value = nested_instance.get_field("device.sensors.0.value")
Setting Field Values
~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# Set simple field
instance.set_field("name", "sensor_02")
# Set nested field
nested_instance.set_field("device.id", "dev_002")
nested_instance.set_field("device.sensors.0.value", 24.1)
Working with Field Information
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# Get field type information
field_info = instance.fields.get_field_info("name")
print(f"Field type: {field_info.type}")
print(f"Field constraints: {field_info.constraints}")
# Check if field exists
if instance.fields.has_field("name"):
print("Field 'name' exists")
Type Constraints and Validation
-------------------------------
.. code-block:: python
from msgcenterpy.core.types import TypeConstraintError
try:
# This will raise an error if type doesn't match
instance.set_field("active", "not_a_boolean")
except TypeConstraintError as e:
print(f"Type constraint violation: {e}")
# Type conversion when possible
instance.set_field("name", 123) # Converts to string if allowed
Message Conversion
------------------
Converting Between Formats
~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
# Create from dictionary
dict_instance = MessageInstance.create(MessageType.DICT, {"key": "value"})
# Convert to JSON Schema instance
schema_instance = dict_instance.to_json_schema()
# Get the actual schema
schema = schema_instance.json_schema
print(schema)
Error Handling
--------------
Common Error Scenarios
~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from msgcenterpy.core.exceptions import FieldAccessError, TypeConstraintError
# Field access errors
try:
value = instance.get_field("nonexistent_field")
except FieldAccessError as e:
print(f"Field not found: {e}")
# Type constraint errors
try:
instance.set_field("active", "invalid_boolean")
except TypeConstraintError as e:
print(f"Invalid type: {e}")
# Graceful handling
def safe_get_field(instance, field_name, default=None):
try:
return instance.get_field(field_name)
except FieldAccessError:
return default
# Usage
value = safe_get_field(instance, "optional_field", "default_value")
Best Practices
--------------
1. **Always handle exceptions** when accessing fields that might not exist
2. **Use type hints** in your code for better development experience
3. **Validate data** before creating instances when working with external data
4. **Use dot notation** for nested field access instead of manual dictionary traversal
5. **Check field existence** before accessing optional fields
Complete Example
----------------
Here's a complete example that demonstrates multiple features:
.. code-block:: python
from msgcenterpy import MessageInstance, MessageType
from msgcenterpy.core.exceptions import FieldAccessError, TypeConstraintError
import json
def process_sensor_data(sensor_data):
"""Process sensor data with proper error handling."""
try:
# Create instance
instance = MessageInstance.create(MessageType.DICT, sensor_data)
# Validate required fields
required_fields = ["id", "type", "readings"]
for field in required_fields:
if not instance.fields.has_field(field):
raise ValueError(f"Missing required field: {field}")
# Process readings
readings = instance.get_field("readings")
if isinstance(readings, list) and len(readings) > 0:
avg_reading = sum(readings) / len(readings)
instance.set_field("average", avg_reading)
# Generate schema for validation
schema = instance.get_json_schema()
# Return processed data and schema
return {
"processed_data": instance.to_dict(),
"schema": schema,
"success": True
}
except (FieldAccessError, TypeConstraintError, ValueError) as e:
return {
"error": str(e),
"success": False
}
# Usage
sensor_data = {
"id": "temp_001",
"type": "temperature",
"readings": [23.1, 23.5, 24.0, 23.8],
"unit": "celsius"
}
result = process_sensor_data(sensor_data)
if result["success"]:
print("Processing successful!")
print(f"Average reading: {result['processed_data']['average']}")
else:
print(f"Processing failed: {result['error']}")

176
docs/index.rst Normal file
View File

@@ -0,0 +1,176 @@
Welcome to MsgCenterPy's Documentation!
========================================
.. image:: https://img.shields.io/badge/license-Apache--2.0-blue.svg
:target: https://github.com/ZGCA-Forge/MsgCenterPy/blob/main/LICENSE
:alt: License
.. image:: https://img.shields.io/pypi/v/msgcenterpy.svg
:target: https://pypi.org/project/msgcenterpy/
:alt: PyPI version
.. image:: https://img.shields.io/pypi/pyversions/msgcenterpy.svg
:target: https://pypi.org/project/msgcenterpy/
:alt: Python versions
MsgCenterPy is a unified message conversion system based on unified instance manager architecture,
supporting seamless conversion between **ROS2**, **Pydantic**, **Dataclass**, **JSON**, **Dict**,
**YAML** and **JSON Schema**.
✨ Key Features
---------------
🔄 **Unified Conversion**: Supports bidirectional conversion between multiple message formats
🤖 **ROS2 Integration**: Complete support for ROS2 message types and constraints
📊 **JSON Schema**: Automatic generation and validation of JSON Schema
🏗️ **Type Safety**: Strong type constraint system based on TypeInfo
🔍 **Field Access**: Unified field accessor interface
**High Performance**: Optimized conversion algorithms and caching mechanism
🧪 **Complete Testing**: 47+ test cases with >90% coverage
📦 Quick Start
--------------
Installation
~~~~~~~~~~~~
.. code-block:: bash
pip install msgcenterpy
Basic Usage
~~~~~~~~~~~
.. code-block:: python
from msgcenterpy import MessageInstance, MessageType
# Create message instance from dictionary
data = {
"name": "sensor_001",
"readings": [1.0, 2.0, 3.0],
"active": True
}
dict_instance = MessageInstance.create(MessageType.DICT, data)
# Generate JSON Schema
schema = dict_instance.get_json_schema()
print(schema)
🎯 Supported Formats
--------------------
.. list-table::
:header-rows: 1
:widths: 20 20 20 20 20
* - Format
- Read
- Write
- JSON Schema
- Type Constraints
* - ROS2
- ✅
- ✅
- ✅
- ✅
* - JSON Schema
- ✅
- ✅
- ✅
- ✅
* - Pydantic
- 开发中
- 开发中
- 开发中
- 开发中
* - Dataclass
- 开发中
- 开发中
- 开发中
- 开发中
* - JSON
- 开发中
- 开发中
- ✅
- ⚡
* - Dict
- 开发中
- 开发中
- ✅
- ⚡
* - YAML
- 开发中
- 开发中
- ✅
- ⚡
.. note::
✅ Fully Supported | 开发中 In Development | ⚡ Basic Support
📚 Documentation Contents
-------------------------
.. toctree::
:maxdepth: 2
:caption: User Guide
installation
quickstart
user_guide/index
.. toctree::
:maxdepth: 2
:caption: Examples
examples/basic_usage
examples/ros2_examples
examples/json_schema_examples
.. toctree::
:maxdepth: 2
:caption: API Reference
api/core
api/instances
api/utils
.. toctree::
:maxdepth: 1
:caption: Development
development/contributing
development/testing
development/changelog
.. toctree::
:maxdepth: 1
:caption: Community
community/support
community/faq
Indices and Tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
🤝 Community & Support
======================
- 📖 **Documentation**: https://zgca-forge.github.io/MsgCenterPy/
- 🐛 **Issues**: https://github.com/ZGCA-Forge/MsgCenterPy/issues
- 💬 **Discussions**: https://github.com/ZGCA-Forge/MsgCenterPy/discussions
📄 License
==========
This project is licensed under the Apache-2.0 License - see the `LICENSE <https://github.com/ZGCA-Forge/MsgCenterPy/blob/main/LICENSE>`_ file for details.

122
docs/installation.rst Normal file
View File

@@ -0,0 +1,122 @@
Installation Guide
==================
This guide will help you install MsgCenterPy in different environments.
Quick Installation
------------------
The easiest way to install MsgCenterPy is using pip:
.. code-block:: bash
pip install msgcenterpy
Requirements
------------
- Python 3.10 or higher (Python 3.11+ recommended for optimal ROS2 compatibility)
- Operating System: Linux, macOS, or Windows
Optional Dependencies
---------------------
ROS2 Support
~~~~~~~~~~~~
To use ROS2 message conversion features:
.. code-block:: bash
pip install msgcenterpy[ros2]
This will install:
- ``rosidl-runtime-py>=0.10.0``
- ``rclpy>=3.0.0``
.. warning::
**ROS2 Python Version Compatibility Notice**
While Python 3.10+ is supported by this package, ROS2 official binary distributions
may have varying support across Python versions. You might need to:
- Build ROS2 from source for newer Python versions
- Use ROS2 distributions that officially support your Python version
- Consider using conda-forge ROS2 packages if available
For production environments, verify ROS2 compatibility in your specific setup.
Development Tools
~~~~~~~~~~~~~~~~~
For development and testing:
.. code-block:: bash
pip install msgcenterpy[dev]
This includes:
- ``pytest>=7.0.0``
- ``black>=22.0.0``
- ``isort>=5.0.0``
- ``mypy>=1.0.0``
- ``pre-commit>=2.20.0``
Documentation Tools
~~~~~~~~~~~~~~~~~~~
For building documentation:
.. code-block:: bash
pip install msgcenterpy[docs]
All Dependencies
~~~~~~~~~~~~~~~~
To install all optional dependencies:
.. code-block:: bash
pip install msgcenterpy[all]
From Source
-----------
Development Installation
~~~~~~~~~~~~~~~~~~~~~~~~
To install from source for development:
.. code-block:: bash
git clone https://github.com/ZGCA-Forge/MsgCenterPy.git
cd MsgCenterPy
pip install -e .[dev]
This will install the package in development mode, allowing you to make changes to the source code.
Verification
------------
To verify your installation:
.. code-block:: python
import msgcenterpy
print(msgcenterpy.get_version())
print(msgcenterpy.check_dependencies())
The output should show the version number and available dependencies.
Troubleshooting
---------------
Common Issues
~~~~~~~~~~~~~
1. **Python Version**: Ensure you're using Python 3.10 or higher (3.11+ recommended for optimal ROS2 compatibility)
2. **ROS2 Dependencies**: ROS2 support requires a proper ROS2 installation
3. **Virtual Environments**: Consider using virtual environments for isolation
If you encounter any issues, please check the `GitHub Issues <https://github.com/ZGCA-Forge/MsgCenterPy/issues>`_ page.

138
docs/quickstart.rst Normal file
View File

@@ -0,0 +1,138 @@
Quick Start Guide
=================
This guide will get you up and running with MsgCenterPy in just a few minutes.
First Steps
-----------
After installation, you can start using MsgCenterPy immediately:
.. code-block:: python
from msgcenterpy import MessageInstance, MessageType
# Create a simple message from dictionary
data = {"name": "test", "value": 42}
instance = MessageInstance.create(MessageType.DICT, data)
# Get JSON Schema
schema = instance.get_json_schema()
print(schema)
Basic Concepts
--------------
MessageInstance
~~~~~~~~~~~~~~~
The core concept in MsgCenterPy is the ``MessageInstance``, which provides a unified interface for different message formats.
.. code-block:: python
from msgcenterpy import MessageInstance, MessageType
# Different ways to create instances
dict_instance = MessageInstance.create(MessageType.DICT, {"key": "value"})
# Access fields
print(dict_instance.get_field("key"))
MessageType
~~~~~~~~~~~
MsgCenterPy supports various message types:
- ``MessageType.DICT``: Python dictionaries
- ``MessageType.JSON_SCHEMA``: JSON Schema objects
- ``MessageType.ROS2``: ROS2 messages (with optional dependency)
Working with ROS2 Messages
---------------------------
If you have ROS2 installed, you can work with ROS2 messages:
.. code-block:: python
from std_msgs.msg import String
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
# Create ROS2 message
msg = String()
msg.data = "Hello ROS2"
# Create instance
ros2_instance = ROS2MessageInstance(msg)
# Convert to JSON Schema
json_schema_instance = ros2_instance.to_json_schema()
print(json_schema_instance.json_schema)
JSON Schema Generation
----------------------
One of the key features is automatic JSON Schema generation:
.. code-block:: python
from msgcenterpy import MessageInstance, MessageType
# Complex nested structure
data = {
"sensor": {
"name": "temperature_01",
"readings": [23.5, 24.1, 23.8],
"metadata": {
"unit": "celsius",
"precision": 0.1
}
}
}
instance = MessageInstance.create(MessageType.DICT, data)
schema = instance.get_json_schema()
# The schema will include type information for all nested structures
Field Access and Type Information
----------------------------------
MsgCenterPy provides detailed type information and field access:
.. code-block:: python
# Access field information
field_info = instance.fields.get_field_info("sensor.name")
print(f"Field type: {field_info.type}")
print(f"Field constraints: {field_info.constraints}")
# Dynamic field access
instance.set_field("sensor.name", "temperature_02")
value = instance.get_field("sensor.name")
Error Handling
--------------
MsgCenterPy includes comprehensive error handling:
.. code-block:: python
try:
# Invalid field access
value = instance.get_field("nonexistent.field")
except FieldAccessError as e:
print(f"Field access error: {e}")
try:
# Type constraint violation
instance.set_field("sensor.readings", "invalid")
except TypeConstraintError as e:
print(f"Type error: {e}")
Next Steps
----------
- Read the :doc:`user_guide/index` for detailed usage
- Check out :doc:`examples/basic_usage` for more examples
- Explore the :doc:`api/core` documentation
- Learn about :doc:`development/contributing` if you want to contribute

14
docs/requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
# Documentation build requirements
sphinx>=5.0.0
sphinx_rtd_theme>=1.0.0
myst-parser>=0.18.0
# For autodoc generation
sphinx-autodoc-typehints>=1.19.0
# Additional Sphinx extensions
sphinx-copybutton>=0.5.0
sphinx-tabs>=3.4.0
# For parsing project dependencies
packaging>=21.0

97
msgcenterpy/__init__.py Normal file
View File

@@ -0,0 +1,97 @@
"""
MsgCenterPy - Unified Message Conversion System
A multi-format message conversion system supporting seamless conversion
between ROS2, Pydantic, Dataclass, JSON, Dict, YAML and JSON Schema.
"""
__version__ = "0.0.2"
__license__ = "Apache-2.0"
from msgcenterpy.core.envelope import MessageEnvelope, create_envelope
from msgcenterpy.core.field_accessor import FieldAccessor
from msgcenterpy.core.message_center import MessageCenter
# Core imports
from msgcenterpy.core.message_instance import MessageInstance
from msgcenterpy.core.type_converter import StandardType, TypeConverter
from msgcenterpy.core.type_info import ConstraintType, TypeInfo
from msgcenterpy.core.types import ConversionError, MessageType, ValidationError
# Always available instance
from msgcenterpy.instances.json_schema_instance import JSONSchemaMessageInstance
# Optional ROS2 instance (with graceful fallback)
try:
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
_HAS_ROS2 = True
except ImportError:
_HAS_ROS2 = False
# Convenience function
def get_message_center() -> MessageCenter:
"""Get the MessageCenter singleton instance."""
return MessageCenter.get_instance()
# Main exports
__all__ = [
# Version info
"__version__",
"__license__",
]
def get_version() -> str:
"""Get the current version of MsgCenterPy."""
return __version__
def get_package_info() -> dict:
"""Get package information."""
return {
"name": "msgcenterpy",
"version": __version__,
"description": "Unified message conversion system supporting ROS2, Pydantic, Dataclass, JSON, YAML, Dict, and JSON Schema inter-conversion",
"license": __license__,
"url": "https://github.com/ZGCA-Forge/MsgCenterPy",
"keywords": [
"message",
"conversion",
"ros2",
"pydantic",
"dataclass",
"json",
"yaml",
"mcp",
],
}
def check_dependencies() -> dict:
"""Check which optional dependencies are available."""
dependencies = {
"ros2": False,
"jsonschema": False,
}
# Check ROS2
try:
import rclpy # type: ignore
import rosidl_runtime_py # type: ignore
dependencies["ros2"] = True
except ImportError:
pass
# Check jsonschema
try:
import jsonschema # type: ignore
dependencies["jsonschema"] = True
except ImportError:
pass
return dependencies

View File

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any, Dict, TypedDict
ENVELOPE_VERSION: str = "1"
class Properties(TypedDict, total=False):
ros_msg_cls_path: str
ros_msg_cls_namespace: str
json_schema: Dict[str, Any]
class FormatMetadata(TypedDict, total=False):
"""Additional metadata for source format, optional.
Examples: field statistics, original type descriptions, field type mappings, etc.
"""
current_format: str
source_cls_name: str
source_cls_module: str
properties: Properties
class MessageEnvelope(TypedDict, total=True):
"""Unified message envelope format.
- version: Protocol version
- format: Source format (MessageType.value)
- type_info: Type information (applicable for ROS2, Pydantic, etc.)
- content: Normalized message content (dictionary)
- metadata: Additional metadata
"""
version: str
format: str
content: Dict[str, Any]
metadata: FormatMetadata
def create_envelope(
*,
format_name: str,
content: Dict[str, Any],
metadata: FormatMetadata,
) -> MessageEnvelope:
env: MessageEnvelope = {
"version": ENVELOPE_VERSION,
"format": format_name,
"content": content,
"metadata": metadata,
}
return env

View File

@@ -0,0 +1,406 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, cast
from msgcenterpy.core.type_converter import StandardType
from msgcenterpy.core.type_info import (
ConstraintType,
Consts,
TypeInfo,
TypeInfoPostProcessor,
)
from msgcenterpy.utils.decorator import experimental
TEST_MODE = True
class FieldAccessor:
"""
字段访问器,支持类型转换和约束验证的统一字段访问接口
只需要getitem和setitem外部必须通过字典的方式来赋值
"""
@property
def parent_msg_center(self) -> Optional["FieldAccessor"]:
return self._parent
@property
def full_path_from_root(self) -> str:
if self._parent is None:
return self._field_name or "unknown"
else:
parent_path = self._parent.full_path_from_root
return f"{parent_path}.{self._field_name or 'unknown'}"
@property
def root_accessor_msg_center(self) -> "FieldAccessor":
"""获取根访问器"""
current = self
while current._parent is not None:
current = current._parent
return current
@property
def value(self) -> Any:
return self._data
@value.setter
def value(self, data: Any) -> None:
if self._parent is not None and self._field_name is not None:
self._parent[self._field_name] = data
@property
def type_info(self) -> Optional[TypeInfo]:
if self._type_info is not None:
return self._type_info
# 如果是根accessor或者没有字段名无法获取TypeInfo
if self._parent is None or self._field_name is None:
return None
# 调用类型信息提供者获取类型信息,调用是耗时的
if self._type_info_provider is None:
return None
type_info = self._type_info_provider.get_field_type_info(self._field_name, self._data, self._parent)
# 对TypeInfo进行后处理添加默认约束
if type_info:
TypeInfoPostProcessor.post_process_type_info(type_info)
self._type_info = type_info
return type_info
"""标记方便排除getitem/setitem不要删除"""
_data: Any = None
_type_info_provider: "TypeInfoProvider" = None # type: ignore[assignment]
_parent: Optional["FieldAccessor"] = None
_field_name: str = None # type: ignore[assignment]
_cache: Dict[str, "FieldAccessor"] = None # type: ignore[assignment]
_type_info: Optional[TypeInfo] = None
def __init__(
self,
data: Any,
type_info_provider: "TypeInfoProvider",
parent: Optional["FieldAccessor"],
field_name: str,
):
"""
初始化字段访问器
Args:
data: 要访问的数据对象
type_info_provider: 类型信息提供者
parent: 父字段访问器,用于嵌套访问
field_name: 当前访问器对应的字段名(用于构建路径)
"""
self._data = data
self._type_info_provider = type_info_provider
self._parent = parent
self._field_name = field_name
self._cache: Dict[str, "FieldAccessor"] = {} # 缓存FieldAccessor而不是TypeInfo
self._type_info: Optional[TypeInfo] = None # 当前accessor的TypeInfo
def get_sub_type_info(self, field_name: str) -> Optional[TypeInfo]:
"""获取字段的类型信息通过获取字段的accessor"""
field_accessor = self[field_name]
return field_accessor.type_info
def __getitem__(self, field_name: str) -> "FieldAccessor":
"""获取字段访问器,支持嵌套访问"""
# 检查缓存中是否有对应的 accessor
if self._cache is None:
self._cache = {}
if field_name in self._cache:
cached_accessor = self._cache[field_name]
# 更新 accessor 的数据源,以防数据已更改
if TEST_MODE:
raw_value = self._get_raw_value(field_name)
if cached_accessor.value != raw_value:
raise ValueError(
f"Cached accessor value mismatch for field '{field_name}': expected {raw_value}, got {cached_accessor.value}"
)
return cached_accessor
# 获取原始值并创建新的 accessor
raw_value = self._get_raw_value(field_name)
if self._type_info_provider is None:
raise RuntimeError("TypeInfoProvider not initialized")
accessor = FieldAccessorFactory.create_accessor(
data=raw_value,
type_info_provider=self._type_info_provider,
parent=self,
field_name=field_name,
)
self._cache[field_name] = accessor
return accessor
def __setitem__(self, field_name: str, value: Any) -> None:
"""设置字段值,支持类型转换和验证"""
# 获取类型信息
if field_name in self._get_field_names():
type_info = self.get_sub_type_info(field_name)
if type_info is not None:
# 进行类型转换
converted_value = type_info.convert_value(value) # 这步自带validate
value = converted_value
# 对子field设置value依然会上溯走set_raw_value确保一致性
# 设置值
sub_accessor = self[field_name]
self._set_raw_value(field_name, value)
sub_accessor._data = self._get_raw_value(field_name) # 有可能内部还有赋值的处理
# 清除相关缓存
self.clear_cache(field_name)
def __contains__(self, field_name: str) -> bool:
return self._has_field(field_name)
def __getattr__(self, field_name: str) -> "FieldAccessor | Any":
"""支持通过属性访问字段,用于嵌套访问如 accessor.pose.position.x"""
for cls in self.__class__.__mro__:
if field_name in cls.__dict__:
return cast(Any, super().__getattribute__(field_name))
return self[field_name]
def __setattr__(self, field_name: str, value: Any) -> None:
"""支持通过属性设置字段值,用于嵌套赋值如 accessor.pose.position.x = 1.0"""
for cls in self.__class__.__mro__:
if field_name in cls.__dict__:
return super().__setattr__(field_name, value)
self[field_name] = value
return None
def clear_cache(self, field_name: Optional[str] = None) -> None:
"""失效字段相关的缓存"""
if self._cache is not None and field_name is not None and field_name in self._cache:
self._cache[field_name].clear_type_info()
def clear_type_info(self) -> None:
"""清空所有缓存"""
if self._type_info is not None:
self._type_info._outdated = True
self._type_info = None
def get_nested_field_accessor(self, path: str, separator: str = ".") -> "FieldAccessor":
parts = path.split(separator)
current = self
for part in parts:
current = self[part]
return current
def set_nested_value(self, path: str, value: Any, separator: str = ".") -> None:
current = self.get_nested_field_accessor(path, separator)
current.value = value
def _get_raw_value(self, field_name: str) -> Any:
"""获取原始字段值(子类实现)"""
if hasattr(self._data, "__getitem__"):
return self._data[field_name]
elif hasattr(self._data, field_name):
return getattr(self._data, field_name)
else:
raise KeyError(f"Field {field_name} not found")
def _set_raw_value(self, field_name: str, value: Any) -> None:
"""设置原始字段值(子类实现)"""
if hasattr(self._data, "__setitem__"):
self._data[field_name] = value
elif hasattr(self._data, field_name):
setattr(self._data, field_name, value)
else:
raise KeyError(f"Field {field_name} not found")
def _has_field(self, field_name: str) -> bool:
"""检查字段是否存在(子类实现)"""
if hasattr(self._data, "__contains__"):
return field_name in self._data
else:
return field_name in self._get_field_names()
def _get_field_names(self) -> list[str]:
"""获取所有字段名(子类实现)"""
if callable(getattr(self._data, "keys", None)):
# noinspection PyCallingNonCallable
return list(self._data.keys())
elif hasattr(self._data, "__dict__"):
return list(self._data.__dict__.keys())
elif hasattr(self._data, "__slots__"):
# noinspection PyTypeChecker
return list(self._data.__slots__)
else:
# 回退方案使用dir()但过滤掉特殊方法
return [name for name in dir(self._data) if not name.startswith("_")]
def get_json_schema(self) -> Dict[str, Any]:
"""原有的递归生成 JSON Schema 逻辑"""
# 获取当前访问器的类型信息
current_type_info = self.type_info
# 如果当前层级有类型信息使用它生成基本schema
if current_type_info is not None:
schema = current_type_info.to_json_schema_property()
else:
# 如果没有类型信息创建基本的object schema
schema = {"type": "object", "additionalProperties": True}
# 如果这是一个对象类型,需要递归处理其字段
if schema.get("type") == "object":
properties = {}
required_fields = []
# 获取所有字段名
field_names = self._get_field_names()
for field_name in field_names:
try:
# 获取字段的访问器
field_accessor = self[field_name]
field_type_info = field_accessor.type_info
if field_type_info is not None:
# 根据字段类型决定如何生成schema
if field_type_info.standard_type == StandardType.OBJECT:
# 对于嵌套对象,递归调用
field_schema = field_accessor.get_json_schema()
else:
# 对于基本类型直接使用type_info转换
field_schema = field_type_info.to_json_schema_property()
properties[field_name] = field_schema
# 检查是否为必需字段
if field_type_info.has_constraint(ConstraintType.REQUIRED):
required_fields.append(field_name)
except Exception as e:
# 如果字段处理失败,记录警告但继续处理其他字段
print(f"Warning: Failed to generate schema for field '{field_name}': {e}")
continue
# 更新schema中的properties
if properties:
schema["properties"] = properties
# 设置必需字段
if required_fields:
schema["required"] = required_fields
# 如果没有字段信息,允许额外属性
if not properties:
schema["additionalProperties"] = True
else:
schema["additionalProperties"] = False
return schema
@experimental("Feature under development")
def update_from_dict(self, source_data: Dict[str, Any]) -> None:
"""递归更新嵌套字典
Args:
source_data: 源数据字典
"""
for key, new_value in source_data.items():
field_exists = self._has_field(key)
could_add = self._could_allow_new_field(key, new_value)
if field_exists:
current_field_accessor = self[key]
current_type_info = current_field_accessor.type_info
# 当前key: Object交给子dict去迭代
if (
current_type_info
and current_type_info.standard_type == StandardType.OBJECT
and isinstance(new_value, dict)
):
current_field_accessor.update_from_dict(new_value)
# 当前key: Array每个值要交给子array去迭代
elif (
current_type_info
and hasattr(current_type_info.standard_type, "IS_ARRAY")
and current_type_info.standard_type.IS_ARRAY
and isinstance(new_value, list)
):
# 存在情况Array嵌套这里后续支持逐个赋值可能需要利用iter进行赋值
self[key] = new_value
else:
# 不限制类型 或 python类型包含
if could_add or (current_type_info and issubclass(type(new_value), current_type_info.python_type)):
self[key] = new_value
else:
raise ValueError(f"{key}")
elif could_add:
self[key] = new_value
def _could_allow_new_field(self, field_name: str, field_value: Any) -> bool:
"""检查是否应该允许添加新字段
通过检查当前type_info中的TYPE_KEEP约束来判断
- 如果有TYPE_KEEP且为True说明类型结构固定不允许添加新字段
- 如果没有TYPE_KEEP约束或为False则允许添加新字段
Args:
field_name: 字段名
field_value: 字段值
Returns:
是否允许添加该字段
"""
parent_type_info = self.type_info
if parent_type_info is None:
return True # DEBUGGER NEEDED
type_keep_constraint = parent_type_info.get_constraint(ConstraintType.TYPE_KEEP)
if type_keep_constraint is not None and type_keep_constraint.value:
return False
return True
class TypeInfoProvider(ABC):
"""Require All Message Instances Extends This get_field_typ_info"""
@abstractmethod
def get_field_type_info(
self, field_name: str, field_value: Any, field_accessor: "FieldAccessor"
) -> Optional[TypeInfo]:
"""获取指定字段的类型信息
Args:
field_name: 字段名,简单字段名如 'field'
field_value: 字段的当前值用于动态类型推断不能为None
field_accessor: 字段访问器提供额外的上下文信息不能为None
Returns:
字段的TypeInfo如果字段不存在则返回None
"""
pass
class ROS2FieldAccessor(FieldAccessor):
def _get_raw_value(self, field_name: str) -> Any:
return getattr(self._data, field_name)
def _set_raw_value(self, field_name: str, value: Any) -> None:
return setattr(self._data, field_name, value)
def _has_field(self, field_name: str) -> bool:
return hasattr(self._data, field_name)
def _get_field_names(self) -> list[str]:
if hasattr(self._data, "_fields_and_field_types"):
# noinspection PyProtectedMember
fields_and_types: Dict[str, str] = cast(Dict[str, str], self._data._fields_and_field_types)
return list(fields_and_types.keys())
else:
return []
class FieldAccessorFactory:
@staticmethod
def create_accessor(
data: Any,
type_info_provider: TypeInfoProvider,
parent: Optional[FieldAccessor] = None,
field_name: str = Consts.ACCESSOR_ROOT_NODE,
) -> FieldAccessor:
if hasattr(data, "_fields_and_field_types"):
return ROS2FieldAccessor(data, type_info_provider, parent, field_name)
else:
return FieldAccessor(data, type_info_provider, parent, field_name)

View File

@@ -0,0 +1,69 @@
from typing import Any, Dict, Optional, Type
from msgcenterpy.core.envelope import MessageEnvelope, Properties
from msgcenterpy.core.message_instance import MessageInstance
from msgcenterpy.core.types import MessageType
class MessageCenter:
"""Message Center singleton class that manages all message types and instances"""
_instance: Optional["MessageCenter"] = None
@classmethod
def get_instance(cls) -> "MessageCenter":
"""Get MessageCenter singleton instance"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self) -> None:
"""Private constructor, use get_instance() to get singleton"""
self._type_registry: Dict[MessageType, Type[MessageInstance]] = {}
self._register_builtin_types()
def _register_builtin_types(self) -> None:
"""Register built-in message types with lazy import to avoid circular dependencies"""
try:
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
self._type_registry[MessageType.ROS2] = ROS2MessageInstance
except ImportError:
pass
try:
from msgcenterpy.instances.json_schema_instance import (
JSONSchemaMessageInstance,
)
self._type_registry[MessageType.JSON_SCHEMA] = JSONSchemaMessageInstance
except ImportError:
pass
def get_instance_class(self, message_type: MessageType) -> Type[MessageInstance]:
"""Get instance class for the specified message type"""
instance_class = self._type_registry.get(message_type)
if not instance_class:
raise ValueError(f"Unsupported message type: {message_type}")
return instance_class
def convert(
self,
source: MessageInstance,
target_type: MessageType,
override_properties: Dict[str, Any],
**kwargs: Any,
) -> MessageInstance:
"""Convert message types"""
target_class = self.get_instance_class(target_type)
dict_data: MessageEnvelope = source.export_to_envelope()
if "properties" not in dict_data["metadata"]:
dict_data["metadata"]["properties"] = Properties()
dict_data["metadata"]["properties"].update(override_properties) # type: ignore[typeddict-item]
target_instance = target_class.import_from_envelope(dict_data)
return target_instance
# Module-level convenience function using singleton
def get_message_center() -> MessageCenter:
"""Get message center singleton"""
return MessageCenter.get_instance()

View File

@@ -0,0 +1,193 @@
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, Type, TypeVar, cast
from msgcenterpy.core.envelope import FormatMetadata, MessageEnvelope, Properties
from msgcenterpy.core.field_accessor import (
FieldAccessor,
FieldAccessorFactory,
TypeInfoProvider,
)
from msgcenterpy.core.types import MessageType
if TYPE_CHECKING:
# 仅用于类型检查的导入,避免运行时循环依赖
from msgcenterpy.instances.json_schema_instance import JSONSchemaMessageInstance
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
T = TypeVar("T")
class MessageInstance(TypeInfoProvider, ABC, Generic[T]):
"""统一消息实例基类"""
_init_ok: bool = False
# 字段访问器相关方法
@property
def fields(self) -> FieldAccessor:
if self._field_accessor is None:
raise RuntimeError("FieldAccessor not initialized")
return self._field_accessor
def __setattr__(self, field_name: str, value: Any) -> None:
if not self._init_ok:
return super().__setattr__(field_name, value)
for cls in self.__class__.__mro__:
if field_name in cls.__dict__:
return super().__setattr__(field_name, value)
self.fields[field_name] = value
return None
def __getattr__(self, field_name: str) -> Any:
if not self._init_ok:
return super().__getattribute__(field_name)
for cls in self.__class__.__mro__:
if field_name in cls.__dict__:
return super().__getattribute__(field_name)
return self.fields[field_name]
def __getitem__(self, field_name: str) -> Any:
"""支持通过下标访问字段"""
return self.fields[field_name]
def __setitem__(self, field_name: str, value: Any) -> None:
"""支持通过下标设置字段"""
self.fields[field_name] = value
def __contains__(self, field_name: str) -> bool:
"""支持in操作符检查字段是否存在"""
return field_name in self.fields
def __init__(
self,
inner_data: T,
message_type: MessageType,
metadata: Optional[FormatMetadata] = None,
):
# 初始化标记和基础属性
self._field_accessor: Optional[FieldAccessor] = None
self._instance_id: str = str(uuid.uuid4())
self.inner_data: T = inner_data # 原始类型数据
self.message_type: MessageType = message_type
self._metadata: FormatMetadata = metadata or FormatMetadata()
self._created_at = datetime.now(timezone.utc)
self._collect_public_properties_to_metadata()
self._field_accessor = FieldAccessorFactory.create_accessor(self.inner_data, self)
self._init_ok = True
def _collect_public_properties_to_metadata(self) -> None:
"""将所有非下划线开头的 @property 的当前值放入 metadata.properties 中。
仅收集只读属性,忽略访问抛出异常的属性。
"""
properties_bucket = self._metadata.setdefault("properties", Properties())
for cls in self.__class__.__mro__:
for attribute_name, attribute_value in cls.__dict__.items():
if attribute_name.startswith("_"):
continue
if isinstance(attribute_value, property):
try:
# 避免重复收集已存在的属性
if attribute_name not in properties_bucket:
properties_bucket[attribute_name] = getattr(self, attribute_name) # type: ignore[literal-required]
except (AttributeError, TypeError, RuntimeError):
# Skip attributes that can't be accessed or have incompatible types
# This includes attributes that require initialization to complete (like 'fields')
pass
def to(self, target_type: MessageType, **kwargs: Any) -> "MessageInstance[Any]":
"""直接转换到目标类型"""
if target_type == MessageType.ROS2:
return cast("MessageInstance[Any]", self.to_ros2(**kwargs))
elif target_type == MessageType.DICT:
return cast("MessageInstance[Any]", self.to_dict(**kwargs))
elif target_type == MessageType.JSON:
return cast("MessageInstance[Any]", self.to_json(**kwargs))
elif target_type == MessageType.JSON_SCHEMA:
return cast("MessageInstance[Any]", self.to_json_schema(**kwargs))
elif target_type == MessageType.YAML:
return cast("MessageInstance[Any]", self.to_yaml(**kwargs))
elif target_type == MessageType.PYDANTIC:
return cast("MessageInstance[Any]", self.to_pydantic(**kwargs))
elif target_type == MessageType.DATACLASS:
return cast("MessageInstance[Any]", self.to_dataclass(**kwargs))
else:
raise ValueError(f"Unsupported target message type: {target_type}")
@classmethod
@abstractmethod
def import_from_envelope(cls, data: MessageEnvelope, **kwargs: Any) -> "MessageInstance[Any]":
"""从统一信封字典创建实例(仅接受 data 一个参数)。"""
# metadata会被重置
pass
@abstractmethod
def export_to_envelope(self, **kwargs: Any) -> MessageEnvelope:
"""导出为字典格式"""
pass
@abstractmethod
def get_python_dict(self) -> Dict[str, Any]:
"""获取当前所有的字段名和对应的python可读值"""
pass
@abstractmethod
def set_python_dict(self, value: Dict[str, Any], **kwargs: Any) -> bool:
"""设置所有字段的值"""
pass
def get_json_schema(self) -> Dict[str, Any]:
"""生成当前消息实例的JSON Schema委托给FieldAccessor递归处理"""
# 直接调用FieldAccessor的get_json_schema方法
schema = self.fields.get_json_schema()
# 添加schema元信息对于JSONSchemaMessageInstance如果已有title则保持否则添加默认title
from msgcenterpy.instances.json_schema_instance import JSONSchemaMessageInstance
if isinstance(self, JSONSchemaMessageInstance):
# 对于JSON Schema实例如果schema中没有title则添加一个
if "title" not in schema:
schema["title"] = f"{self.__class__.__name__} Schema" # type: ignore
if "description" not in schema:
schema["description"] = f"JSON Schema generated from {self.message_type.value} message instance" # type: ignore
else:
# 对于其他类型的实例总是添加schema元信息
schema["title"] = f"{self.__class__.__name__} Schema" # type: ignore
schema["description"] = f"JSON Schema generated from {self.message_type.value} message instance" # type: ignore
return schema
def __repr__(self) -> str:
return f"{self.__class__.__name__}(type={self.message_type.value}, id={self._instance_id[:8]})"
# 便捷转换方法使用MessageCenter单例
def to_ros2(self, type_hint: str | Type[Any], **kwargs: Any) -> "ROS2MessageInstance":
"""转换到ROS2实例。传入必备的类型提示"""
override_properties = {}
from msgcenterpy.core.message_center import get_message_center
ros2_message_instance = cast(
ROS2MessageInstance,
get_message_center().get_instance_class(MessageType.ROS2),
)
ros_type = ros2_message_instance.obtain_ros_cls_from_str(type_hint)
override_properties["ros_msg_cls_path"] = ROS2MessageInstance.get_ros_msg_cls_path(ros_type)
override_properties["ros_msg_cls_namespace"] = ROS2MessageInstance.get_ros_msg_cls_namespace(ros_type)
return cast(
ROS2MessageInstance,
get_message_center().convert(self, MessageType.ROS2, override_properties, **kwargs),
)
def to_json_schema(self, **kwargs: Any) -> "JSONSchemaMessageInstance":
"""转换到JSON Schema实例"""
override_properties = {"json_schema": self.get_json_schema()}
from msgcenterpy.core.message_center import get_message_center
from msgcenterpy.instances.json_schema_instance import JSONSchemaMessageInstance
return cast(
JSONSchemaMessageInstance,
get_message_center().convert(self, MessageType.JSON_SCHEMA, override_properties, **kwargs),
)

View File

@@ -0,0 +1,411 @@
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Any, Dict, Type, Union, get_args, get_origin
class StandardType(Enum):
"""标准化的数据类型,用于不同数据源之间的转换
增强版本,提供更细粒度的类型保留以更好地保存原始类型信息
"""
# 基础类型
STRING = "string" # 字符串类型
WSTRING = "wstring" # 宽字符串类型
CHAR = "char" # 字符类型
WCHAR = "wchar" # 宽字符类型
# 整数类型(细分以保留精度信息)
INT8 = "int8" # 8位有符号整数
UINT8 = "uint8" # 8位无符号整数
INT16 = "int16" # 16位有符号整数
UINT16 = "uint16" # 16位无符号整数
INT32 = "int32" # 32位有符号整数
UINT32 = "uint32" # 32位无符号整数
INT64 = "int64" # 64位有符号整数
UINT64 = "uint64" # 64位无符号整数
INTEGER = "integer" # 通用整数类型(向后兼容)
BYTE = "byte" # 字节类型
OCTET = "octet" # 八位字节类型
# 浮点类型(细分以保留精度信息)
FLOAT32 = "float32" # 32位浮点数
FLOAT64 = "float64" # 64位浮点数双精度
DOUBLE = "double" # 双精度浮点数
FLOAT = "float" # 通用浮点类型(向后兼容)
# 布尔类型
BOOLEAN = "boolean" # 布尔类型
BOOL = "bool" # 布尔类型ROS2风格
# 空值类型
NULL = "null" # 空值类型
# 容器类型
ARRAY = "array" # 数组/序列类型
BOUNDED_ARRAY = "bounded_array" # 有界数组类型
UNBOUNDED_ARRAY = "unbounded_array" # 无界数组类型
SEQUENCE = "sequence" # 序列类型
BOUNDED_SEQUENCE = "bounded_sequence" # 有界序列类型
UNBOUNDED_SEQUENCE = "unbounded_sequence" # 无界序列类型
OBJECT = "object" # 对象/映射类型
# 扩展类型
DATETIME = "datetime" # 日期时间类型
TIME = "time" # 时间类型
DURATION = "duration" # 持续时间类型
BYTES = "bytes" # 字节数据类型
DECIMAL = "decimal" # 精确小数类型
# 特殊类型
UNKNOWN = "unknown" # 未知类型
ANY = "any" # 任意类型
@property
def IS_ARRAY(self) -> bool:
"""判断该类型是否为数组/序列类型"""
array_like = {
StandardType.ARRAY,
StandardType.BOUNDED_ARRAY,
StandardType.UNBOUNDED_ARRAY,
StandardType.SEQUENCE,
StandardType.BOUNDED_SEQUENCE,
StandardType.UNBOUNDED_SEQUENCE,
}
return self in array_like
class TypeConverter:
"""类型转换器,负责不同数据源类型之间的转换和标准化"""
# Python基础类型到标准类型的映射
PYTHON_TO_STANDARD = {
str: StandardType.STRING,
int: StandardType.INTEGER, # 保持向后兼容的通用整数
float: StandardType.FLOAT, # 保持向后兼容的通用浮点
bool: StandardType.BOOLEAN,
type(None): StandardType.NULL,
list: StandardType.ARRAY,
tuple: StandardType.ARRAY,
dict: StandardType.OBJECT,
datetime: StandardType.DATETIME,
bytes: StandardType.BYTES,
bytearray: StandardType.BYTES,
Decimal: StandardType.DECIMAL,
}
# 标准类型到Python类型的映射
STANDARD_TO_PYTHON = {
# 字符串类型
StandardType.STRING: str,
StandardType.WSTRING: str,
StandardType.CHAR: str,
StandardType.WCHAR: str,
# 整数类型都映射到intPython会自动处理范围
StandardType.INT8: int,
StandardType.UINT8: int,
StandardType.INT16: int,
StandardType.UINT16: int,
StandardType.INT32: int,
StandardType.UINT32: int,
StandardType.INT64: int,
StandardType.UINT64: int,
StandardType.INTEGER: int,
StandardType.BYTE: int,
StandardType.OCTET: int,
# 浮点类型
StandardType.FLOAT32: float,
StandardType.FLOAT64: float,
StandardType.DOUBLE: float,
StandardType.FLOAT: float,
# 布尔类型
StandardType.BOOLEAN: bool,
StandardType.BOOL: bool,
# 空值类型
StandardType.NULL: type(None),
# 容器类型
StandardType.ARRAY: list,
StandardType.BOUNDED_ARRAY: list,
StandardType.UNBOUNDED_ARRAY: list,
StandardType.SEQUENCE: list,
StandardType.BOUNDED_SEQUENCE: list,
StandardType.UNBOUNDED_SEQUENCE: list,
StandardType.OBJECT: dict,
# 扩展类型
StandardType.DATETIME: datetime,
StandardType.TIME: datetime,
StandardType.DURATION: float, # 持续时间用秒表示
StandardType.BYTES: bytes,
StandardType.DECIMAL: Decimal,
# 特殊类型
StandardType.UNKNOWN: object,
StandardType.ANY: object,
}
# ROS2类型到标准类型的映射保留原始类型精度
ROS2_TO_STANDARD = {
# 字符串类型(保留具体类型)
"string": StandardType.STRING,
"wstring": StandardType.WSTRING,
"char": StandardType.CHAR,
"wchar": StandardType.WCHAR,
# 整数类型(保留精度信息)
"int8": StandardType.INT8,
"uint8": StandardType.UINT8,
"int16": StandardType.INT16,
"short": StandardType.INT16, # to check
"uint16": StandardType.UINT16,
"unsigned short": StandardType.UINT16, # to check
"int32": StandardType.INT32,
"uint32": StandardType.UINT32,
"int64": StandardType.INT64,
"long": StandardType.INT64, # to check
"long long": StandardType.INT64, # to check
"uint64": StandardType.UINT64,
"unsigned long": StandardType.UINT64, # to check
"unsigned long long": StandardType.UINT64, # to check
"byte": StandardType.BYTE,
"octet": StandardType.OCTET,
# 浮点类型(保留精度信息)
"float32": StandardType.FLOAT32,
"float64": StandardType.FLOAT64,
"double": StandardType.DOUBLE,
"long double": StandardType.DOUBLE,
"float": StandardType.FLOAT32, # 默认为32位
# 布尔类型
"bool": StandardType.BOOL,
"boolean": StandardType.BOOLEAN,
# 时间和持续时间(更精确的类型映射)
"time": StandardType.TIME,
"duration": StandardType.DURATION,
# 向后兼容的通用映射(当需要时可以回退到这些)
"generic_int": StandardType.INTEGER,
"generic_float": StandardType.FLOAT,
"generic_bool": StandardType.BOOLEAN,
"generic_string": StandardType.STRING,
}
# JSON Schema类型到标准类型的映射
JSON_SCHEMA_TO_STANDARD = {
"string": StandardType.STRING,
"integer": StandardType.INTEGER,
"number": StandardType.FLOAT,
"boolean": StandardType.BOOLEAN,
"null": StandardType.NULL,
"array": StandardType.ARRAY,
"object": StandardType.OBJECT,
}
# 标准类型到Python类型的映射
STANDARD_TO_JSON_SCHEMA = {
# 字符串类型
StandardType.STRING: "string",
StandardType.WSTRING: "string",
StandardType.CHAR: "string",
StandardType.WCHAR: "string",
# 整数类型都映射到intPython会自动处理范围
StandardType.INT8: "integer",
StandardType.UINT8: "integer",
StandardType.INT16: "integer",
StandardType.UINT16: "integer",
StandardType.INT32: "integer",
StandardType.UINT32: "integer",
StandardType.INT64: "integer",
StandardType.UINT64: "integer",
StandardType.INTEGER: "integer",
StandardType.BYTE: "integer",
StandardType.OCTET: "integer",
# 浮点类型
StandardType.FLOAT32: "number",
StandardType.FLOAT64: "number",
StandardType.DOUBLE: "number",
StandardType.FLOAT: "number",
# 布尔类型
StandardType.BOOLEAN: "boolean",
StandardType.BOOL: "boolean",
# 空值类型
StandardType.NULL: "null",
# 容器类型
StandardType.ARRAY: "array",
StandardType.BOUNDED_ARRAY: "array",
StandardType.UNBOUNDED_ARRAY: "array",
StandardType.SEQUENCE: "array",
StandardType.BOUNDED_SEQUENCE: "array",
StandardType.UNBOUNDED_SEQUENCE: "array",
StandardType.OBJECT: "object",
# 扩展类型
StandardType.DATETIME: "string", # 在JSON Schema中日期时间通常表示为字符串
StandardType.TIME: "string",
StandardType.DURATION: "number",
StandardType.BYTES: "string", # 字节数据在JSON Schema中通常表示为base64字符串
StandardType.DECIMAL: "number",
# 特殊类型
StandardType.UNKNOWN: "string",
StandardType.ANY: "string",
}
# Array typecode到标准类型的映射更精确的类型保留
ARRAY_TYPECODE_TO_STANDARD = {
"b": StandardType.INT8, # signed char
"B": StandardType.UINT8, # unsigned char
"h": StandardType.INT16, # signed short
"H": StandardType.UINT16, # unsigned short
"i": StandardType.INT32, # signed int
"I": StandardType.UINT32, # unsigned int
"l": StandardType.INT64, # signed long
"L": StandardType.UINT64, # unsigned long
"f": StandardType.FLOAT32, # float
"d": StandardType.FLOAT64, # double
}
# Array typecode到Python类型的映射
ARRAY_TYPECODE_TO_PYTHON = {
"b": int, # signed char
"B": int, # unsigned char
"h": int, # signed short
"H": int, # unsigned short
"i": int, # signed int
"I": int, # unsigned int
"l": int, # signed long
"L": int, # unsigned long
"f": float, # float
"d": float, # double
}
"""Python Type"""
@classmethod
def python_type_to_standard(cls, python_type: Type) -> StandardType:
"""将Python类型转换为标准类型"""
# 处理泛型类型
origin = get_origin(python_type)
if origin is not None:
if origin in (list, tuple):
return StandardType.ARRAY
elif origin is dict:
return StandardType.OBJECT
elif origin in (Union, type(Union[int, None])):
# 处理Optional类型和Union类型
args = get_args(python_type)
non_none_types = [arg for arg in args if arg != type(None)]
if len(non_none_types) == 1:
return cls.python_type_to_standard(non_none_types[0])
return StandardType.ANY
# 处理基础类型
return cls.PYTHON_TO_STANDARD.get(python_type, StandardType.UNKNOWN)
@classmethod
def standard_to_python_type(cls, standard_type: StandardType) -> Type:
"""将标准类型转换为Python类型"""
return cls.STANDARD_TO_PYTHON.get(standard_type, object)
"""ROS2"""
@classmethod
def ros2_type_str_to_standard(cls, ros2_type_str: str) -> StandardType:
"""将ROS2类型字符串转换为标准类型"""
if "[" in ros2_type_str and "]" in ros2_type_str:
return StandardType.ARRAY
base_type = ros2_type_str.split("/")[-1].lower()
return cls.ROS2_TO_STANDARD.get(base_type, StandardType.UNKNOWN)
@classmethod
def rosidl_definition_to_standard(cls, definition_type: Any) -> StandardType:
from rosidl_parser.definition import ( # type: ignore
Array,
BasicType,
BoundedSequence,
BoundedString,
BoundedWString,
NamedType,
NamespacedType,
UnboundedSequence,
UnboundedString,
UnboundedWString,
)
# 基础类型转换(保留原始类型精度)
if isinstance(definition_type, BasicType):
type_name = definition_type.typename.lower()
return cls.ros2_type_str_to_standard(type_name)
# 字符串类型(区分普通字符串和宽字符串)
elif isinstance(definition_type, (UnboundedString, BoundedString)):
return StandardType.STRING
elif isinstance(definition_type, (UnboundedWString, BoundedWString)):
return StandardType.WSTRING
# 数组和序列类型(更精确的类型区分)
elif isinstance(definition_type, Array):
return StandardType.BOUNDED_ARRAY
elif isinstance(definition_type, UnboundedSequence):
return StandardType.UNBOUNDED_SEQUENCE
elif isinstance(definition_type, BoundedSequence):
return StandardType.BOUNDED_SEQUENCE
# 命名类型和命名空间类型统一为OBJECT
elif isinstance(definition_type, (NamedType, NamespacedType)):
return StandardType.OBJECT
# 未知类型
else:
return StandardType.UNKNOWN
@classmethod
def array_typecode_to_standard(cls, typecode: str) -> StandardType:
"""将array.array的typecode转换为标准类型"""
return cls.ARRAY_TYPECODE_TO_STANDARD.get(typecode, StandardType.UNKNOWN)
"""JSON Schema"""
@classmethod
def json_schema_type_to_standard(cls, json_type: str) -> StandardType:
"""将JSON Schema类型转换为标准类型"""
return cls.JSON_SCHEMA_TO_STANDARD.get(json_type, StandardType.UNKNOWN)
@classmethod
def standard_type_to_json_schema_type(cls, standard_type: StandardType) -> str:
"""将StandardType转换为JSON Schema类型字符串"""
return cls.STANDARD_TO_JSON_SCHEMA.get(standard_type, "string")
"""值转换"""
@classmethod
def convert_to_python_value_with_standard_type(cls, value: Any, target_standard_type: StandardType) -> Any:
"""将值转换为指定的标准类型对应的Python值"""
if value is None:
return None if target_standard_type == StandardType.NULL else None
target_python_type = cls.standard_to_python_type(target_standard_type)
if target_python_type != object and type(value) == target_python_type:
# object交由target_standard_type为OBJECT的分支处理同样返回原值
return value
if target_standard_type == StandardType.ARRAY:
if isinstance(value, (list, tuple)):
return list(value)
elif hasattr(value, "typecode"): # array.array
return list(value)
elif isinstance(value, str):
return list(value) # 字符串转为字符数组
else:
return [value] # 单个值包装为数组
elif target_standard_type == StandardType.OBJECT:
return value
elif target_standard_type == StandardType.DATETIME:
if isinstance(value, datetime):
return value
elif isinstance(value, (int, float)):
return datetime.fromtimestamp(value)
elif isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
else:
return datetime.now()
elif target_standard_type == StandardType.BYTES:
if isinstance(value, bytes):
return value
elif isinstance(value, str):
return value.encode("utf-8")
elif isinstance(value, (list, tuple)):
return bytes(value)
else:
return str(value).encode("utf-8")
else:
# 基础类型转换
return target_python_type(value)

View File

@@ -0,0 +1,400 @@
import copy
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Type
from msgcenterpy.core.type_converter import StandardType, TypeConverter
class Consts:
ELEMENT_TYPE_INFO_SYMBOL = "ELEMENT_TYPE_INFO"
ACCESSOR_ROOT_NODE = "MSG_CENTER_ROOT"
class ConstraintType(Enum):
"""Constraint type enumeration"""
MIN_VALUE = "min_value"
MAX_VALUE = "max_value"
MIN_LENGTH = "min_length"
MAX_LENGTH = "max_length"
MIN_ITEMS = "min_items"
MAX_ITEMS = "max_items"
PATTERN = "pattern"
ENUM_VALUES = "enum_values"
MULTIPLE_OF = "multiple_of"
TYPE_KEEP = "type_keep"
EXCLUSIVE_MIN = "exclusive_min"
EXCLUSIVE_MAX = "exclusive_max"
UNIQUE_ITEMS = "unique_items"
DEFAULT_VALUE = "default_value"
REQUIRED = "required"
FORMAT = "format"
@dataclass
class TypeConstraint:
"""Type constraint definition"""
type: ConstraintType
value: Any
description: Optional[str] = None
def to_json_schema_property(self) -> Dict[str, Any]:
"""Convert to JSON Schema property"""
mapping = {
ConstraintType.MIN_VALUE: "minimum",
ConstraintType.MAX_VALUE: "maximum",
ConstraintType.MIN_LENGTH: "minLength",
ConstraintType.MAX_LENGTH: "maxLength",
ConstraintType.MIN_ITEMS: "minItems",
ConstraintType.MAX_ITEMS: "maxItems",
ConstraintType.PATTERN: "pattern",
ConstraintType.ENUM_VALUES: "enum",
ConstraintType.MULTIPLE_OF: "multipleOf",
ConstraintType.EXCLUSIVE_MIN: "exclusiveMinimum",
ConstraintType.EXCLUSIVE_MAX: "exclusiveMaximum",
ConstraintType.UNIQUE_ITEMS: "uniqueItems",
ConstraintType.DEFAULT_VALUE: "default",
ConstraintType.FORMAT: "format",
}
property_name = mapping.get(self.type)
if property_name:
result = {property_name: self.value}
if self.description:
result["description"] = self.description
return result
return {}
@dataclass
class TypeInfo:
"""Detailed type information including standard type, Python type and constraints"""
# Basic type information
field_name: str
field_path: str
standard_type: StandardType
python_type: Type
original_type: Any # Original type (e.g., ROS2 type instance)
_outdated: bool = False
@property
def outdated(self) -> bool:
return self._outdated
# Value information
current_value: Any = None
default_value: Any = None
# Constraints
constraints: List[TypeConstraint] = field(default_factory=list)
# Array/sequence related information
is_array: bool = False
array_size: Optional[int] = None # Fixed size array
_element_type_info: Optional["TypeInfo"] = None # Array element type
@property
def element_type_info(self) -> Optional["TypeInfo"]:
return self._element_type_info
@element_type_info.setter
def element_type_info(self, value: Optional["TypeInfo"]) -> None:
if self.outdated:
raise ValueError("Should not change an outdated type")
if value is not None:
value.field_name = Consts.ELEMENT_TYPE_INFO_SYMBOL
value.field_path = Consts.ELEMENT_TYPE_INFO_SYMBOL
self._element_type_info = value
# Object related information
is_object: bool = False
object_fields: Dict[str, "TypeInfo"] = field(default_factory=dict)
# Additional metadata
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def python_value_from_standard_type(self) -> Any:
return TypeConverter.convert_to_python_value_with_standard_type(self.current_value, self.standard_type)
def add_constraint(
self,
constraint_type: ConstraintType,
value: Any,
description: Optional[str] = None,
) -> None:
"""Add constraint"""
constraint = TypeConstraint(constraint_type, value, description)
# Avoid duplicate constraints of the same type
self.constraints = [c for c in self.constraints if c.type != constraint_type]
self.constraints.append(constraint)
def get_constraint(self, constraint_type: ConstraintType) -> Optional[TypeConstraint]:
"""Get constraint of specified type"""
for constraint in self.constraints:
if constraint.type == constraint_type:
return constraint
return None
def has_constraint(self, constraint_type: ConstraintType) -> bool:
"""Check if constraint of specified type exists"""
return self.get_constraint(constraint_type) is not None
def get_constraint_value(self, constraint_type: ConstraintType) -> Any:
"""Get value of specified constraint"""
constraint = self.get_constraint(constraint_type)
return constraint.value if constraint else None
def validate_value(self, value: Any) -> bool:
"""Validate value according to constraints"""
try:
if self.get_constraint(ConstraintType.TYPE_KEEP):
# ROS includes TYPE_KEEP
if type(self.current_value) != type(value):
return False
# Basic type check
if not self._validate_basic_type(value):
return False
# Numeric constraint check
if not self._validate_numeric_constraints(value):
return False
# String constraint check
if not self._validate_string_constraints(value):
return False
# Array constraint check
if not self._validate_array_constraints(value):
return False
return True
except Exception:
return False
def _validate_basic_type(self, value: Any) -> bool:
"""Validate basic type"""
if value is None:
return not self.has_constraint(ConstraintType.REQUIRED)
return True
def _validate_numeric_constraints(self, value: Any) -> bool:
"""Validate numeric constraints"""
if not isinstance(value, (int, float)):
return True
min_val = self.get_constraint_value(ConstraintType.MIN_VALUE)
if min_val is not None and value < min_val:
return False
max_val = self.get_constraint_value(ConstraintType.MAX_VALUE)
if max_val is not None and value > max_val:
return False
exclusive_min = self.get_constraint_value(ConstraintType.EXCLUSIVE_MIN)
if exclusive_min is not None and value <= exclusive_min:
return False
exclusive_max = self.get_constraint_value(ConstraintType.EXCLUSIVE_MAX)
if exclusive_max is not None and value >= exclusive_max:
return False
multiple_of = self.get_constraint_value(ConstraintType.MULTIPLE_OF)
if multiple_of is not None and value % multiple_of != 0:
return False
return True
def _validate_string_constraints(self, value: Any) -> bool:
"""Validate string constraints"""
if not isinstance(value, str):
return True
min_len = self.get_constraint_value(ConstraintType.MIN_LENGTH)
if min_len is not None and len(value) < min_len:
return False
max_len = self.get_constraint_value(ConstraintType.MAX_LENGTH)
if max_len is not None and len(value) > max_len:
return False
pattern = self.get_constraint_value(ConstraintType.PATTERN)
if pattern is not None:
import re
if not re.match(pattern, value):
return False
enum_values = self.get_constraint_value(ConstraintType.ENUM_VALUES)
if enum_values is not None and value not in enum_values:
return False
return True
def _validate_array_constraints(self, value: Any) -> bool:
"""Validate array constraints"""
if not isinstance(value, (list, tuple)):
return True
min_items = self.get_constraint_value(ConstraintType.MIN_ITEMS)
if min_items is not None and len(value) < min_items:
return False
max_items = self.get_constraint_value(ConstraintType.MAX_ITEMS)
if max_items is not None and len(value) > max_items:
return False
if self.array_size is not None and len(value) != self.array_size:
return False
unique_items = self.get_constraint_value(ConstraintType.UNIQUE_ITEMS)
if unique_items and len(set(value)) != len(value):
return False
return True
def to_json_schema_property(self, include_constraints: bool = True) -> Dict[str, Any]:
"""Convert to JSON Schema property definition"""
from msgcenterpy.core.type_converter import TypeConverter
# Basic properties
property_schema: Dict[str, Any] = {"type": TypeConverter.standard_type_to_json_schema_type(self.standard_type)}
# Add constraints
if include_constraints:
for constraint in self.constraints:
constraint_props = constraint.to_json_schema_property()
property_schema.update(constraint_props)
# Special handling for array types
if self.is_array and self.element_type_info:
property_schema["items"] = self.element_type_info.to_json_schema_property(include_constraints)
# Special handling for object types
if self.is_object and self.object_fields:
properties = {}
for field_name, field_info in self.object_fields.items():
properties[field_name] = field_info.to_json_schema_property(include_constraints)
property_schema["properties"] = properties
# Add description
if self.original_type:
property_schema["description"] = f"Field of type {self.original_type}"
return property_schema
def convert_value(self, value: Any, target_standard_type: Optional[StandardType] = None) -> Any:
"""Convert value to current type or specified target type"""
target_type = target_standard_type or self.standard_type
converted_value = TypeConverter.convert_to_python_value_with_standard_type(value, target_type)
# Validate converted value
if target_standard_type is None and not self.validate_value(converted_value):
# Format constraint information
constraints_info = []
for c in self.constraints:
constraint_desc = f"{c.type.value}: {c.value}"
if c.description:
constraint_desc += f" ({c.description})"
constraints_info.append(constraint_desc)
constraints_str = ", ".join(constraints_info) if constraints_info else "No constraints"
raise ValueError(
f"Value {value} does not meet constraints for field {self.field_name}. "
f"Constraints: [{constraints_str}]"
)
return converted_value
def get_value_info(self) -> Dict[str, Any]:
"""Get detailed information about current value"""
return {
"field_name": self.field_name,
"current_value": self.current_value,
"standard_type": self.standard_type.value,
"python_type": self.python_type.__name__,
"original_type": self.original_type,
"is_valid": self.validate_value(self.current_value),
"constraints": [
{"type": c.type.value, "value": c.value, "description": c.description} for c in self.constraints
],
"is_array": self.is_array,
"array_size": self.array_size,
"is_object": self.is_object,
"metadata": self.metadata,
}
def clone(self) -> "TypeInfo":
"""Create deep copy of TypeInfo"""
return copy.deepcopy(self)
def __repr__(self) -> str:
constraints_str = f", {len(self.constraints)} constraints" if self.constraints else ""
return f"TypeInfo({self.field_name}: {self.standard_type.value}{constraints_str})"
class TypeInfoPostProcessor:
"""TypeInfo post-processor that adds default constraints to TypeInfo"""
@staticmethod
def add_basic_type_constraints(type_info: TypeInfo) -> None:
"""Add range constraints for basic types"""
if not type_info.standard_type:
return
standard_type = type_info.standard_type
# Integer type range constraints
if standard_type == StandardType.INT8:
type_info.add_constraint(ConstraintType.MIN_VALUE, -128)
type_info.add_constraint(ConstraintType.MAX_VALUE, 127)
elif standard_type in (
StandardType.UINT8,
StandardType.BYTE,
StandardType.OCTET,
):
type_info.add_constraint(ConstraintType.MIN_VALUE, 0)
type_info.add_constraint(ConstraintType.MAX_VALUE, 255)
elif standard_type == StandardType.INT16:
type_info.add_constraint(ConstraintType.MIN_VALUE, -32768)
type_info.add_constraint(ConstraintType.MAX_VALUE, 32767)
elif standard_type == StandardType.UINT16:
type_info.add_constraint(ConstraintType.MIN_VALUE, 0)
type_info.add_constraint(ConstraintType.MAX_VALUE, 65535)
elif standard_type == StandardType.INT32:
type_info.add_constraint(ConstraintType.MIN_VALUE, -2147483648)
type_info.add_constraint(ConstraintType.MAX_VALUE, 2147483647)
elif standard_type == StandardType.UINT32:
type_info.add_constraint(ConstraintType.MIN_VALUE, 0)
type_info.add_constraint(ConstraintType.MAX_VALUE, 4294967295)
elif standard_type == StandardType.INT64:
type_info.add_constraint(ConstraintType.MIN_VALUE, -9223372036854775808)
type_info.add_constraint(ConstraintType.MAX_VALUE, 9223372036854775807)
elif standard_type == StandardType.UINT64:
type_info.add_constraint(ConstraintType.MIN_VALUE, 0)
type_info.add_constraint(ConstraintType.MAX_VALUE, 18446744073709551615)
# Floating point type range constraints
elif standard_type in (StandardType.FLOAT, StandardType.FLOAT32):
type_info.add_constraint(ConstraintType.MIN_VALUE, -3.4028235e38)
type_info.add_constraint(ConstraintType.MAX_VALUE, 3.4028235e38)
elif standard_type in (StandardType.DOUBLE, StandardType.FLOAT64):
type_info.add_constraint(ConstraintType.MIN_VALUE, -1.7976931348623157e308)
type_info.add_constraint(ConstraintType.MAX_VALUE, 1.7976931348623157e308)
@staticmethod
def add_default_constraints(type_info: TypeInfo) -> None:
"""Add default constraints"""
field_value = type_info.current_value
# Add constraints for array types
if isinstance(field_value, (list, tuple)):
type_info.is_array = True
# 不再添加冗余的 MIN_ITEMS: 0
@staticmethod
def post_process_type_info(type_info: TypeInfo) -> None:
"""Post-process TypeInfo, adding various default constraints"""
TypeInfoPostProcessor.add_basic_type_constraints(type_info)
TypeInfoPostProcessor.add_default_constraints(type_info)

25
msgcenterpy/core/types.py Normal file
View File

@@ -0,0 +1,25 @@
from enum import Enum
class MessageType(Enum):
"""Supported message types"""
ROS2 = "ros2"
PYDANTIC = "pydantic"
DATACLASS = "dataclass"
JSON = "json"
JSON_SCHEMA = "json_schema"
DICT = "dict"
YAML = "yaml"
class ConversionError(Exception):
"""Conversion error exception"""
pass
class ValidationError(Exception):
"""Validation error exception"""
pass

View File

View File

@@ -0,0 +1,303 @@
from typing import TYPE_CHECKING, Any, Dict, Optional
import jsonschema
from msgcenterpy.core.envelope import MessageEnvelope, create_envelope
from msgcenterpy.core.message_instance import MessageInstance
from msgcenterpy.core.type_converter import TypeConverter
from msgcenterpy.core.type_info import ConstraintType, Consts, TypeInfo
from msgcenterpy.core.types import MessageType
if TYPE_CHECKING:
from msgcenterpy.core.field_accessor import FieldAccessor
class JSONSchemaMessageInstance(MessageInstance[Dict[str, Any]]):
"""JSON Schema消息实例支持类型信息提取和字段访问器"""
_validation_errors: list[str] = []
_json_schema: Dict[str, Any] = dict()
_json_data: Dict[str, Any] = dict()
def __init__(self, inner_data: Dict[str, Any], schema: Dict[str, Any], **kwargs: Any) -> None:
"""
初始化JSON Schema消息实例
Args:
inner_data: JSON数据字典
schema: JSON Schema定义必需
"""
# 直接存储schema和data
self._json_schema = schema
self._json_data = inner_data
self._validation_errors = []
# 验证数据
self._validate_data()
super().__init__(inner_data, MessageType.JSON_SCHEMA)
@property
def json_schema(self) -> Dict[str, Any]:
"""获取JSON Schema"""
return self._json_schema
def _validate_data(self) -> None:
"""根据schema验证数据"""
try:
jsonschema.validate(self._json_data, self._json_schema)
except jsonschema.ValidationError as e:
# 不抛出异常,只记录验证错误
self._validation_errors = [str(e)]
except Exception:
self._validation_errors = ["Schema validation failed"]
else:
self._validation_errors = []
def export_to_envelope(self, **kwargs: Any) -> MessageEnvelope:
"""导出为统一信封字典"""
base_dict = self.get_python_dict()
envelope = create_envelope(
format_name=self.message_type.value,
content=base_dict,
metadata={
"current_format": self.message_type.value,
"source_cls_name": self.__class__.__name__,
"source_cls_module": self.__class__.__module__,
**self._metadata,
},
)
return envelope
@classmethod
def import_from_envelope(cls, data: MessageEnvelope, **kwargs: Any) -> "JSONSchemaMessageInstance":
"""从规范信封创建JSON Schema实例"""
content = data["content"]
properties = data["metadata"]["properties"]
json_schema = properties["json_schema"]
instance = cls(content, json_schema)
return instance
def get_python_dict(self) -> Dict[str, Any]:
"""获取当前所有的字段名和对应的原始值"""
return self._json_data.copy()
def set_python_dict(self, value: Dict[str, Any], **kwargs: Any) -> bool:
"""设置所有字段的值,只做已有字段的更新"""
# 获取根访问器
root_accessor = self._field_accessor
if root_accessor is not None:
root_accessor.update_from_dict(source_data=value)
# 重新验证数据
self._validate_data()
return True
def _get_schema_from_path(self, path: str) -> Dict[str, Any]:
"""根据访问器路径获取对应的JSON Schema定义
Args:
path: 字段访问器的完整路径,如 "MSG_CENTER_ROOT.user.address"
Returns:
对应路径的JSON Schema定义
"""
# 移除根路径前缀
if path.startswith(Consts.ACCESSOR_ROOT_NODE):
if path == Consts.ACCESSOR_ROOT_NODE:
return self._json_schema
path = path[len(Consts.ACCESSOR_ROOT_NODE) + 1 :]
# 如果路径为空返回根schema
if not path:
return self._json_schema
# 分割路径并逐级导航
path_parts = path.split(".")
current_schema = self._json_schema
for part in path_parts:
# 检查当前schema是否有properties
if "properties" not in current_schema:
return {}
properties = current_schema["properties"]
if part not in properties:
return {}
current_schema = properties[part]
# 如果当前schema是数组需要获取items的schema
if current_schema.get("type") == "array" and "items" in current_schema:
current_schema = current_schema["items"]
return current_schema
def _get_property_schema_for_field(self, field_name: str, parent_field_accessor: "FieldAccessor") -> Dict[str, Any]:
"""获取字段的JSON Schema属性定义
Args:
field_name: 字段名
parent_field_accessor: 父级字段访问器
Returns:
字段的JSON Schema属性定义
"""
# 获取父级的schema定义
parent_schema = self._get_schema_from_path(parent_field_accessor.full_path_from_root)
# 从父级schema的properties中获取字段定义
if "properties" in parent_schema:
return parent_schema["properties"].get(field_name, {}) # type: ignore[no-any-return]
elif parent_schema.get("type") == "array" and "items" in parent_schema:
# 如果父级是数组获取items的属性
items_schema = parent_schema["items"]
if "properties" in items_schema:
return items_schema["properties"].get(field_name, {}) # type: ignore[no-any-return]
return {}
# TypeInfoProvider 接口实现
def get_field_type_info(
self, field_name: str, field_value: Any, parent_field_accessor: "FieldAccessor"
) -> Optional[TypeInfo]:
"""从JSON Schema定义中提取字段类型信息"""
# 构建完整路径
full_path = f"{parent_field_accessor.full_path_from_root}.{field_name}"
# 获取字段的JSON Schema定义
property_schema = self._get_property_schema_for_field(field_name, parent_field_accessor)
# 确定类型信息
python_type = type(field_value)
if "type" in property_schema:
json_type = property_schema["type"]
standard_type = TypeConverter.json_schema_type_to_standard(json_type)
else:
# 如果schema中没有类型定义从Python类型推断
standard_type = TypeConverter.python_type_to_standard(python_type)
json_type = TypeConverter.standard_type_to_json_schema_type(standard_type)
# 创建基础TypeInfo
type_info = TypeInfo(
field_name=field_name,
field_path=full_path,
standard_type=standard_type,
python_type=python_type,
original_type=json_type,
current_value=field_value,
)
# 提取约束信息
self._extract_constraints_from_schema(type_info, property_schema)
# 检查字段是否在父级的required列表中
parent_schema = self._get_schema_from_path(parent_field_accessor.full_path_from_root)
required_fields = parent_schema.get("required", [])
if field_name in required_fields:
type_info.add_constraint(ConstraintType.REQUIRED, True, "Field is required by JSON Schema")
# 处理数组类型
if json_type == "array":
type_info.is_array = True
self._extract_array_constraints(type_info, property_schema)
# 处理对象类型
elif json_type == "object":
type_info.is_object = True
self._extract_object_constraints(type_info, property_schema)
# 设置默认值
if "default" in property_schema:
type_info.default_value = property_schema["default"]
return type_info
@classmethod
def _extract_constraints_from_schema(cls, type_info: TypeInfo, property_schema: Dict[str, Any]) -> None:
"""从JSON Schema属性中提取约束条件"""
# 数值约束
if "minimum" in property_schema:
type_info.add_constraint(ConstraintType.MIN_VALUE, property_schema["minimum"])
if "maximum" in property_schema:
type_info.add_constraint(ConstraintType.MAX_VALUE, property_schema["maximum"])
if "exclusiveMinimum" in property_schema:
type_info.add_constraint(ConstraintType.EXCLUSIVE_MIN, property_schema["exclusiveMinimum"])
if "exclusiveMaximum" in property_schema:
type_info.add_constraint(ConstraintType.EXCLUSIVE_MAX, property_schema["exclusiveMaximum"])
if "multipleOf" in property_schema:
type_info.add_constraint(ConstraintType.MULTIPLE_OF, property_schema["multipleOf"])
# 字符串约束
if "minLength" in property_schema:
type_info.add_constraint(ConstraintType.MIN_LENGTH, property_schema["minLength"])
if "maxLength" in property_schema:
type_info.add_constraint(ConstraintType.MAX_LENGTH, property_schema["maxLength"])
if "pattern" in property_schema:
type_info.add_constraint(ConstraintType.PATTERN, property_schema["pattern"])
# 枚举约束
if "enum" in property_schema:
type_info.add_constraint(ConstraintType.ENUM_VALUES, property_schema["enum"])
# 格式约束
if "format" in property_schema:
type_info.add_constraint(ConstraintType.FORMAT, property_schema["format"])
# 默认值
if "default" in property_schema:
type_info.add_constraint(ConstraintType.DEFAULT_VALUE, property_schema["default"])
@classmethod
def _extract_array_constraints(cls, type_info: TypeInfo, property_schema: Dict[str, Any]) -> None:
"""提取数组类型的约束"""
if "minItems" in property_schema:
type_info.add_constraint(ConstraintType.MIN_ITEMS, property_schema["minItems"])
if "maxItems" in property_schema:
type_info.add_constraint(ConstraintType.MAX_ITEMS, property_schema["maxItems"])
if "uniqueItems" in property_schema:
type_info.add_constraint(ConstraintType.UNIQUE_ITEMS, property_schema["uniqueItems"])
# 提取数组元素类型信息
items_schema = property_schema.get("items")
if isinstance(items_schema, dict) and "type" in items_schema:
element_type = TypeConverter.json_schema_type_to_standard(items_schema["type"])
type_info.element_type_info = TypeInfo(
field_name=f"{type_info.field_name}_item",
field_path=f"{type_info.field_path}_item",
standard_type=element_type,
python_type=TypeConverter.standard_to_python_type(element_type),
original_type=items_schema["type"],
current_value=None,
)
# 递归提取元素约束
cls._extract_constraints_from_schema(type_info.element_type_info, items_schema)
@classmethod
def _extract_object_constraints(cls, type_info: TypeInfo, property_schema: Dict[str, Any]) -> None:
"""提取对象类型的约束"""
# 对象类型的属性定义
properties = property_schema.get("properties", {})
required_fields = property_schema.get("required", [])
for prop_name, prop_schema in properties.items():
if isinstance(prop_schema, dict) and "type" in prop_schema:
prop_type = TypeConverter.json_schema_type_to_standard(prop_schema["type"])
prop_type_info = TypeInfo(
field_name=prop_name,
field_path=f"{type_info.field_path}.{prop_name}",
standard_type=prop_type,
python_type=TypeConverter.standard_to_python_type(prop_type),
original_type=prop_schema["type"],
current_value=None,
)
# 递归提取属性约束
cls._extract_constraints_from_schema(prop_type_info, prop_schema)
# 如果字段在required列表中添加REQUIRED约束
if prop_name in required_fields:
prop_type_info.add_constraint(
ConstraintType.REQUIRED,
True,
"Field is required by JSON Schema",
)
type_info.object_fields[prop_name] = prop_type_info

View File

@@ -0,0 +1,242 @@
import array
import importlib
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Dict, Optional, Type
from rosidl_parser.definition import NamespacedType # type: ignore
from rosidl_runtime_py import ( # type: ignore
import_message_from_namespaced_type,
message_to_ordereddict,
set_message_fields,
)
from msgcenterpy.core.envelope import MessageEnvelope, create_envelope
from msgcenterpy.core.message_instance import MessageInstance
from msgcenterpy.core.type_converter import TypeConverter
from msgcenterpy.core.type_info import ConstraintType, Consts, TypeInfo
from msgcenterpy.core.types import MessageType
if TYPE_CHECKING:
from msgcenterpy.core.field_accessor import FieldAccessor
class ROS2MessageInstance(MessageInstance[Any]):
"""ROS2消息实例支持类型信息提取和字段访问器"""
ros_msg_cls: Type[Any] = None # type: ignore
@classmethod
def get_ros_msg_cls_path(cls, ros_msg_cls: Type[Any]) -> str:
return ros_msg_cls.__module__ + "." + ros_msg_cls.__name__
@property
def ros_msg_cls_path(self) -> str:
return self.get_ros_msg_cls_path(self.ros_msg_cls)
@classmethod
def get_ros_msg_cls_namespace(cls, ros_msg_cls: Type[Any]) -> str:
class_name, module_name = ros_msg_cls.__name__, ros_msg_cls.__module__
package = module_name.split(".")[0] if module_name else ""
interface = (
"msg"
if ".msg" in module_name
else "srv"
if ".srv" in module_name
else "action"
if ".action" in module_name
else "msg"
)
return f"{package}/{interface}/{class_name}" if package and class_name else f"{module_name}.{class_name}"
@property
def ros_msg_cls_namespace(self) -> str:
return self.get_ros_msg_cls_namespace(self.ros_msg_cls)
@classmethod
def obtain_ros_cls_from_str(cls, message_type: str | Type[Any]) -> Type[Any]:
# 需要先解析出正确的消息类
if isinstance(message_type, str):
if "/" in message_type:
namespace, name = message_type.rsplit("/", 1)
message_type = import_message_from_namespaced_type(NamespacedType(namespace.split("/"), name))
elif "." in message_type:
module_path, class_name = message_type.rsplit(".", 1)
mod = importlib.import_module(module_path)
message_type = getattr(mod, class_name)
return message_type # type: ignore
def __init__(self, inner_data: Any, **kwargs: Any) -> None:
self.ros_msg_cls = inner_data.__class__
if not isinstance(self.ros_msg_cls, type):
raise TypeError(f"Expected ROS message class to be a type, got {type(self.ros_msg_cls)}")
super().__init__(inner_data, MessageType.ROS2)
def export_to_envelope(self, **kwargs: Any) -> MessageEnvelope:
"""导出为统一信封字典
用户可从 metadata.properties 中读取:
- properties.ros_msg_cls_namespace
- properties.ros_msg_cls_path
"""
base_dict = self.get_python_dict()
export_envelope = create_envelope(
format_name=self.message_type.value,
content=base_dict,
metadata={
"current_format": self.message_type.value,
"source_cls_name": self.inner_data.__class__.__name__,
"source_cls_module": self.inner_data.__class__.__module__,
**self._metadata,
},
)
return export_envelope
@classmethod
def _ordered_to_dict(cls, obj: Any) -> Any:
if isinstance(obj, OrderedDict):
return {k: cls._ordered_to_dict(v) for k, v in obj.items()}
elif isinstance(obj, tuple):
return tuple(cls._ordered_to_dict(v) for v in obj)
elif isinstance(obj, (list, array.array)):
return [cls._ordered_to_dict(v) for v in obj]
else:
return obj
@classmethod
def import_from_envelope(cls, data: MessageEnvelope, **kwargs: Any) -> "ROS2MessageInstance":
"""从规范信封创建ROS2实例仅 data 一个参数)。
类型信息从 data.metadata.properties 读取
"""
content = data["content"]
properties = data["metadata"]["properties"]
ros_msg_cls = cls.obtain_ros_cls_from_str(properties["ros_msg_cls_namespace"]) or cls.obtain_ros_cls_from_str(
properties["ros_msg_cls_path"]
)
if ros_msg_cls is None:
raise ValueError(
"ros2 type must be provided via metadata.properties.ros_msg_cls_namespace or legacy type_info.ros_namespaced"
)
ros_msg = ros_msg_cls()
set_message_fields(ros_msg, content)
instance = ROS2MessageInstance(ros_msg)
return instance
def get_python_dict(self) -> Dict[str, Any]:
"""获取当前所有的字段名和对应的原始值,使用 SLOT_TYPES 进行类型推断和嵌套导入"""
base_obj = message_to_ordereddict(self.inner_data)
base_dict = self._ordered_to_dict(base_obj)
return base_dict # type: ignore[no-any-return]
def set_python_dict(self, value: Dict[str, Any], **kwargs: Any) -> bool:
"""获取当前所有的字段名和对应的原始值,使用 SLOT_TYPES 进行类型推断和嵌套导入"""
timestamp_fields = set_message_fields(self.inner_data, value, **kwargs)
# todo: 因为ROS自身机制字段并不会增减所以需要更新cache中所有accessor的值通过parent获取
return True
# TypeInfoProvider 接口实现
def get_field_type_info(
self, field_name: str, field_value: Any, parent_field_accessor: "FieldAccessor"
) -> Optional[TypeInfo]:
"""从ROS2消息定义中提取字段类型信息
使用 ROS 消息的 SLOT_TYPES 获取精确的类型信息,并通过 TypeConverter 转换为标准类型
"""
# 通过 parent_field_accessor 获取 ROS 消息实例
ros_msg_instance = parent_field_accessor.value
# 构建完整路径用于TypeInfo
full_path = f"{parent_field_accessor.full_path_from_root}.{field_name}"
# noinspection PyProtectedMember
slots = ros_msg_instance._fields_and_field_types
slot_types = ros_msg_instance.SLOT_TYPES
# 通过 zip 找到 field_name 对应的类型定义
ros_definition_type = None
for slot_name, slot_type in zip(slots, slot_types):
if slot_name == field_name:
ros_definition_type = slot_type
break
if ros_definition_type is None:
raise ValueError(f"Field '{field_name}' not found in ROS message slots")
# 使用 TypeConverter 转换为标准类型
standard_type = TypeConverter.rosidl_definition_to_standard(ros_definition_type)
# 创建 TypeInfo
type_info = TypeInfo(
field_name=field_name,
field_path=full_path,
standard_type=standard_type,
python_type=type(field_value),
original_type=ros_definition_type,
)
type_info.current_value = field_value
# 从 rosidl 定义中提取详细类型信息(约束、数组信息等)
self._extract_from_rosidl_definition(type_info)
return type_info
def _extract_from_rosidl_definition(self, type_info: TypeInfo) -> None:
"""从rosidl_parser定义中提取详细类型信息
Args:
type_info: 要填充的TypeInfo对象
"""
from rosidl_parser.definition import (
AbstractNestedType,
Array,
BasicType,
BoundedSequence,
BoundedString,
BoundedWString,
NamespacedType,
UnboundedSequence,
)
# 从type_info获取所需信息
definition_type = type_info.original_type
get_element_type = False
# 提取约束信息
if isinstance(definition_type, (BoundedString, BoundedWString)):
type_info.add_constraint(ConstraintType.MAX_LENGTH, definition_type.maximum_size)
elif isinstance(definition_type, Array):
type_info.is_array = True
type_info.array_size = definition_type.size
type_info.add_constraint(ConstraintType.MIN_ITEMS, definition_type.size)
type_info.add_constraint(ConstraintType.MAX_ITEMS, definition_type.size)
get_element_type = True
elif isinstance(definition_type, BoundedSequence):
type_info.is_array = True
type_info.add_constraint(ConstraintType.MAX_ITEMS, definition_type.maximum_size)
get_element_type = True
elif isinstance(definition_type, UnboundedSequence):
type_info.is_array = True
get_element_type = True
elif isinstance(definition_type, BasicType):
# 基础类型的约束将在 field_accessor 中自动添加
pass
elif isinstance(definition_type, NamespacedType):
# 对象类型,标记为对象并提取字段信息
type_info.is_object = True
type_info.add_constraint(ConstraintType.TYPE_KEEP, True)
# 这里可以进一步扩展来提取对象字段信息
# 提取元素类型信息
if get_element_type:
if not isinstance(definition_type, AbstractNestedType):
raise TypeError(f"Expected AbstractNestedType for element type extraction, got {type(definition_type)}")
# 创建元素类型的TypeInfo并递归填充
std_type = TypeConverter.rosidl_definition_to_standard(definition_type.value_type)
python_type = TypeConverter.standard_to_python_type(std_type)
type_info.element_type_info = TypeInfo(
field_name=Consts.ELEMENT_TYPE_INFO_SYMBOL,
field_path=Consts.ELEMENT_TYPE_INFO_SYMBOL,
standard_type=std_type,
python_type=python_type,
original_type=definition_type.value_type,
)
self._extract_from_rosidl_definition(type_info.element_type_info)

View File

View File

@@ -0,0 +1,29 @@
import functools
import warnings
from typing import Any, Callable
def experimental(
reason: str = "This API is experimental and may change or be removed in future.",
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
装饰器:标记函数为实验性。
调用时会发出 RuntimeWarning。
:param reason: 警告信息,可以自定义说明原因
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
warnings.warn(
f"Call to experimental function '{func.__name__}': {reason}",
category=RuntimeWarning,
stacklevel=2,
)
return func(*args, **kwargs)
wrapper.__experimental__ = True # type: ignore[attr-defined] # 给函数打个标记,方便外部检测
return wrapper
return decorator

106
pyproject.toml Normal file
View File

@@ -0,0 +1,106 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "msgcenterpy"
dynamic = ["version"]
description = "Unified message conversion system supporting ROS2, Pydantic, Dataclass, JSON, YAML, Dict, and MCP schema inter-conversion"
readme = "README.md"
requires-python = ">=3.10"
license = {file = "LICENSE"}
authors = [
{name = "MsgCenterPy Team", email = "zgca@zgca.com"}
]
keywords = ["message", "conversion", "ros2", "pydantic", "dataclass", "json", "yaml", "mcp"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: System :: Distributed Computing",
"Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator"
]
dependencies = [
"pydantic>=2.0.0",
"jsonschema>=4.25",
"PyYAML>=6.0",
"typing-extensions>=4.0.0; python_version<'3.11'"
]
[project.optional-dependencies]
ros2 = [
"rosidl_runtime_py>=0.9.0",
"rclpy>=3.0.0"
]
dev = [
"pytest>=7.0.0",
"black>=22.0.0",
"isort>=5.0.0",
"mypy>=1.0.0",
"types-jsonschema>=4.0.0",
"types-PyYAML>=6.0.0",
"pre-commit>=2.20.0",
"bump2version>=1.0.0"
]
docs = [
"sphinx>=5.0.0",
"sphinx-rtd-theme>=1.0.0",
"myst-parser>=0.18.0"
]
all = [
"msgcenterpy[ros2,dev,docs]"
]
[project.urls]
Homepage = "https://github.com/ZGCA-Forge/MsgCenterPy"
Documentation = "https://zgca-forge.github.io/MsgCenterPy/"
Repository = "https://github.com/ZGCA-Forge/MsgCenterPy"
Issues = "https://github.com/ZGCA-Forge/MsgCenterPy/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["msgcenterpy*"]
[tool.setuptools.dynamic]
version = {attr = "msgcenterpy.__version__"}
[tool.black]
line-length = 120
target-version = ['py310']
[tool.isort]
profile = "black"
multi_line_output = 3
[tool.mypy]
python_version = "3.10"
plugins = ["pydantic.mypy"]
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
strict_equality = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = "-v --tb=short --strict-markers --strict-config -ra --color=yes"
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning"
]
[tool.bandit]
exclude_dirs = ["tests", "build", "dist"]
skips = ["B101", "B601"]

59
scripts/setup-dev.ps1 Normal file
View File

@@ -0,0 +1,59 @@
Write-Host "[INFO] Setting up MsgCenterPy development environment..." -ForegroundColor Green
# Check if Python is available
try {
$pythonVersion = python --version
Write-Host "[SUCCESS] Found: $pythonVersion" -ForegroundColor Green
} catch {
Write-Host "[ERROR] Python 3 is required but not found in PATH." -ForegroundColor Red
Write-Host "Please install Python 3.10+ and add it to your PATH." -ForegroundColor Yellow
exit 1
}
# Install the package in development mode
Write-Host "[INFO] Installing package in development mode..." -ForegroundColor Blue
pip install -e .[dev]
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Failed to install package in development mode" -ForegroundColor Red
exit 1
}
# Install pre-commit hooks
Write-Host "[INFO] Installing pre-commit hooks..." -ForegroundColor Blue
pre-commit install
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Failed to install pre-commit hooks" -ForegroundColor Red
exit 1
}
# Install pre-commit hooks for commit-msg (optional)
Write-Host "[INFO] Installing commit-msg hooks..." -ForegroundColor Blue
pre-commit install --hook-type commit-msg
if ($LASTEXITCODE -ne 0) {
Write-Host "[WARNING] commit-msg hooks installation failed (optional)" -ForegroundColor Yellow
}
# Run pre-commit on all files to check setup
Write-Host "[INFO] Running pre-commit on all files to verify setup..." -ForegroundColor Blue
pre-commit run --all-files
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Pre-commit setup completed successfully!" -ForegroundColor Green
Write-Host ""
Write-Host "[SUCCESS] You're all set! Pre-commit will now run automatically on every commit." -ForegroundColor Green
Write-Host ""
Write-Host "[INFO] Quick commands:" -ForegroundColor Cyan
Write-Host " • Run all hooks manually: pre-commit run --all-files" -ForegroundColor White
Write-Host " • Update hook versions: pre-commit autoupdate" -ForegroundColor White
Write-Host " • Skip hooks for one commit: git commit --no-verify" -ForegroundColor White
Write-Host " • Run tests: pytest" -ForegroundColor White
Write-Host " • Type checking: mypy msgcenterpy" -ForegroundColor White
} else {
Write-Host "[WARNING] Pre-commit found some issues. Please fix them and run 'pre-commit run --all-files' again." -ForegroundColor Yellow
Write-Host "[TIP] Or use 'pre-commit run --all-files --show-diff-on-failure' to see what needs to be fixed." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "[INFO] Integration with CI:" -ForegroundColor Cyan
Write-Host " • CI will run the same pre-commit hooks" -ForegroundColor White
Write-Host " • If you skip pre-commit locally, CI will catch the issues" -ForegroundColor White
Write-Host " • Best practice: Always let pre-commit fix issues before committing" -ForegroundColor White

46
scripts/setup-dev.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
set -e # Exit on any error
echo "[INFO] Setting up MsgCenterPy development environment..."
# Check if Python is available
if ! command -v python3 &> /dev/null; then
echo "[ERROR] Python 3 is required but not installed."
exit 1
fi
# Install the package in development mode
echo "[INFO] Installing package in development mode..."
pip install -e .[dev]
# Install pre-commit hooks
echo "[INFO] Installing pre-commit hooks..."
pre-commit install
# Install pre-commit hooks for commit-msg (optional)
echo "[INFO] Installing commit-msg hooks..."
pre-commit install --hook-type commit-msg || echo "[WARNING] commit-msg hooks installation failed (optional)"
# Run pre-commit on all files to check setup
echo "[INFO] Running pre-commit on all files to verify setup..."
if pre-commit run --all-files; then
echo "[SUCCESS] Pre-commit setup completed successfully!"
echo ""
echo "[SUCCESS] You're all set! Pre-commit will now run automatically on every commit."
echo ""
echo "[INFO] Quick commands:"
echo " • Run all hooks manually: pre-commit run --all-files"
echo " • Update hook versions: pre-commit autoupdate"
echo " • Skip hooks for one commit: git commit --no-verify"
echo " • Run tests: pytest"
echo " • Type checking: mypy msgcenterpy"
else
echo "[WARNING] Pre-commit found some issues. Please fix them and run 'pre-commit run --all-files' again."
echo "[TIP] Or use 'pre-commit run --all-files --show-diff-on-failure' to see what needs to be fixed."
fi
echo ""
echo "[INFO] Integration with CI:"
echo " • CI will run the same pre-commit hooks"
echo " • If you skip pre-commit locally, CI will catch the issues"
echo " • Best practice: Always let pre-commit fix issues before committing"

69
setup-dev.ps1 Normal file
View File

@@ -0,0 +1,69 @@
# Development environment setup script for MsgCenterPy (Windows PowerShell)
Write-Host "🚀 Setting up MsgCenterPy development environment..." -ForegroundColor Green
# Check if Python is available
try {
$pythonVersion = python --version
Write-Host "✅ Found: $pythonVersion" -ForegroundColor Green
} catch {
Write-Host "❌ Error: Python 3 is required but not found in PATH." -ForegroundColor Red
Write-Host "Please install Python 3.10+ and add it to your PATH." -ForegroundColor Yellow
exit 1
}
# Install the package in development mode
Write-Host "📦 Installing package in development mode..." -ForegroundColor Blue
pip install -e .[dev]
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Failed to install package in development mode" -ForegroundColor Red
exit 1
}
# Install pre-commit
Write-Host "🔧 Installing pre-commit..." -ForegroundColor Blue
pip install pre-commit
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Failed to install pre-commit" -ForegroundColor Red
exit 1
}
# Install pre-commit hooks
Write-Host "🪝 Installing pre-commit hooks..." -ForegroundColor Blue
pre-commit install
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ Failed to install pre-commit hooks" -ForegroundColor Red
exit 1
}
# Install pre-commit hooks for commit-msg (optional)
Write-Host "📝 Installing commit-msg hooks..." -ForegroundColor Blue
pre-commit install --hook-type commit-msg
if ($LASTEXITCODE -ne 0) {
Write-Host "⚠️ commit-msg hooks installation failed (optional)" -ForegroundColor Yellow
}
# Run pre-commit on all files to check setup
Write-Host "🔍 Running pre-commit on all files to verify setup..." -ForegroundColor Blue
pre-commit run --all-files
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ Pre-commit setup completed successfully!" -ForegroundColor Green
Write-Host ""
Write-Host "🎉 You're all set! Pre-commit will now run automatically on every commit." -ForegroundColor Green
Write-Host ""
Write-Host "📋 Quick commands:" -ForegroundColor Cyan
Write-Host " • Run all hooks manually: pre-commit run --all-files" -ForegroundColor White
Write-Host " • Update hook versions: pre-commit autoupdate" -ForegroundColor White
Write-Host " • Skip hooks for one commit: git commit --no-verify" -ForegroundColor White
Write-Host " • Run tests: pytest" -ForegroundColor White
Write-Host " • Type checking: mypy msgcenterpy" -ForegroundColor White
} else {
Write-Host "⚠️ Pre-commit found some issues. Please fix them and run 'pre-commit run --all-files' again." -ForegroundColor Yellow
Write-Host "💡 Or use 'pre-commit run --all-files --show-diff-on-failure' to see what needs to be fixed." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "🔗 Integration with CI:" -ForegroundColor Cyan
Write-Host " • CI will run the same pre-commit hooks" -ForegroundColor White
Write-Host " • If you skip pre-commit locally, CI will catch the issues" -ForegroundColor White
Write-Host " • Best practice: Always let pre-commit fix issues before committing" -ForegroundColor White

52
setup-dev.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Development environment setup script for MsgCenterPy
set -e # Exit on any error
echo "🚀 Setting up MsgCenterPy development environment..."
# Check if Python is available
if ! command -v python3 &> /dev/null; then
echo "❌ Error: Python 3 is required but not installed."
exit 1
fi
# Install the package in development mode
echo "📦 Installing package in development mode..."
pip install -e .[dev]
# Install pre-commit
echo "🔧 Installing pre-commit..."
pip install pre-commit
# Install pre-commit hooks
echo "🪝 Installing pre-commit hooks..."
pre-commit install
# Install pre-commit hooks for commit-msg (optional)
echo "📝 Installing commit-msg hooks..."
pre-commit install --hook-type commit-msg || echo "⚠️ commit-msg hooks installation failed (optional)"
# Run pre-commit on all files to check setup
echo "🔍 Running pre-commit on all files to verify setup..."
if pre-commit run --all-files; then
echo "✅ Pre-commit setup completed successfully!"
echo ""
echo "🎉 You're all set! Pre-commit will now run automatically on every commit."
echo ""
echo "📋 Quick commands:"
echo " • Run all hooks manually: pre-commit run --all-files"
echo " • Update hook versions: pre-commit autoupdate"
echo " • Skip hooks for one commit: git commit --no-verify"
echo " • Run tests: pytest"
echo " • Type checking: mypy msgcenterpy"
else
echo "⚠️ Pre-commit found some issues. Please fix them and run 'pre-commit run --all-files' again."
echo "💡 Or use 'pre-commit run --all-files --show-diff-on-failure' to see what needs to be fixed."
fi
echo ""
echo "🔗 Integration with CI:"
echo " • CI will run the same pre-commit hooks"
echo " • If you skip pre-commit locally, CI will catch the issues"
echo " • Best practice: Always let pre-commit fix issues before committing"

View File

@@ -0,0 +1,463 @@
import os
import sys
import pytest
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# JSON Schema 依赖检查
from msgcenterpy.core.type_info import ConstraintType
from msgcenterpy.core.types import MessageType
from msgcenterpy.instances.json_schema_instance import JSONSchemaMessageInstance
class TestJSONSchemaMessageInstance:
"""JSONSchemaMessageInstance 基本功能测试"""
@pytest.fixture
def simple_schema(self):
"""简单的 JSON Schema 示例"""
return {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"active": {"type": "boolean"},
},
"required": ["name"],
}
@pytest.fixture
def complex_schema(self):
"""复杂的 JSON Schema 示例"""
return {
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"id": {"type": "string"},
"profile": {
"type": "object",
"properties": {
"email": {"type": "string", "format": "email"},
"phone": {
"type": "string",
"pattern": "^\\+?[1-9]\\d{1,14}$",
},
},
},
},
"required": ["id"],
},
"tags": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 10,
},
"scores": {
"type": "array",
"items": {"type": "number", "minimum": 0, "maximum": 100},
},
"metadata": {"type": "object", "additionalProperties": True},
},
"required": ["user", "tags"],
}
@pytest.fixture
def simple_data(self):
"""匹配简单 schema 的数据"""
return {"name": "John Doe", "age": 30, "active": True}
@pytest.fixture
def complex_data(self):
"""匹配复杂 schema 的数据"""
return {
"user": {
"id": "user_123",
"profile": {"email": "john@example.com", "phone": "+1234567890"},
},
"tags": ["developer", "python", "testing"],
"scores": [85.5, 92.0, 78.3],
"metadata": {"created_at": "2024-01-01", "version": 1},
}
def test_basic_creation(self, simple_schema, simple_data):
"""测试基本创建功能"""
instance = JSONSchemaMessageInstance(simple_data, simple_schema)
assert instance.message_type == MessageType.JSON_SCHEMA
assert instance.inner_data == simple_data
assert instance.json_schema == simple_schema
assert len(instance._validation_errors) == 0
def test_data_validation_success(self, simple_schema, simple_data):
"""测试数据验证成功"""
instance = JSONSchemaMessageInstance(simple_data, simple_schema)
# 验证成功,没有错误
assert len(instance._validation_errors) == 0
def test_data_validation_failure(self, simple_schema):
"""测试数据验证失败"""
invalid_data = {
"age": -5, # 违反 minimum 约束
"active": "not_boolean", # 类型错误
# 缺少必需的 "name" 字段
}
instance = JSONSchemaMessageInstance(invalid_data, simple_schema)
# 应该有验证错误
assert len(instance._validation_errors) > 0
def test_get_python_dict(self, simple_schema, simple_data):
"""测试获取 Python 字典"""
instance = JSONSchemaMessageInstance(simple_data, simple_schema)
result = instance.get_python_dict()
assert isinstance(result, dict)
assert result == simple_data
assert result is not simple_data # 应该是副本
def test_set_python_dict(self, simple_schema, simple_data):
"""测试设置 Python 字典"""
instance = JSONSchemaMessageInstance(simple_data, simple_schema)
new_data = {"name": "Jane Smith", "age": 25}
result = instance.set_python_dict(new_data)
assert result is True
assert instance.get_python_dict()["name"] == "Jane Smith"
assert instance.get_python_dict()["age"] == 25
def test_export_to_envelope(self, simple_schema, simple_data):
"""测试导出信封"""
instance = JSONSchemaMessageInstance(simple_data, simple_schema)
envelope = instance.export_to_envelope()
assert "content" in envelope
assert "metadata" in envelope
assert envelope["content"] == simple_data
metadata = envelope["metadata"]
assert metadata["current_format"] == "json_schema"
assert "properties" in metadata
def test_import_from_envelope(self, simple_schema, simple_data):
"""测试从信封导入"""
# 创建原始实例
original = JSONSchemaMessageInstance(simple_data, simple_schema)
envelope = original.export_to_envelope()
# 从信封导入新实例
new_instance = JSONSchemaMessageInstance.import_from_envelope(envelope)
assert isinstance(new_instance, JSONSchemaMessageInstance)
assert new_instance.get_python_dict() == simple_data
assert new_instance.json_schema == simple_schema
class TestJSONSchemaFieldTypeInfo:
"""JSON Schema 字段类型信息测试"""
@pytest.fixture
def typed_schema(self):
"""包含各种类型的 schema"""
return {
"type": "object",
"properties": {
"string_field": {"type": "string", "minLength": 3, "maxLength": 50},
"integer_field": {"type": "integer", "minimum": 0, "maximum": 100},
"number_field": {"type": "number", "multipleOf": 0.5},
"boolean_field": {"type": "boolean"},
"array_field": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 5,
},
"object_field": {
"type": "object",
"properties": {"nested_string": {"type": "string"}},
},
"enum_field": {
"type": "string",
"enum": ["option1", "option2", "option3"],
},
"format_field": {"type": "string", "format": "email"},
},
"required": ["string_field", "integer_field"],
}
@pytest.fixture
def typed_data(self):
"""匹配类型化 schema 的数据"""
return {
"string_field": "hello",
"integer_field": 42,
"number_field": 3.5,
"boolean_field": True,
"array_field": ["item1", "item2"],
"object_field": {"nested_string": "nested_value"},
"enum_field": "option1",
"format_field": "test@example.com",
}
def test_string_field_type_info(self, typed_schema, typed_data):
"""测试字符串字段类型信息"""
instance = JSONSchemaMessageInstance(typed_data, typed_schema)
type_info = instance.fields.get_sub_type_info("string_field")
assert type_info is not None
assert type_info.field_name == "string_field"
assert type_info.standard_type.value == "string"
assert type_info.current_value == "hello"
# 检查约束
assert type_info.has_constraint(ConstraintType.MIN_LENGTH)
assert type_info.get_constraint_value(ConstraintType.MIN_LENGTH) == 3
assert type_info.has_constraint(ConstraintType.MAX_LENGTH)
assert type_info.get_constraint_value(ConstraintType.MAX_LENGTH) == 50
assert type_info.has_constraint(ConstraintType.REQUIRED)
def test_integer_field_type_info(self, typed_schema, typed_data):
"""测试整数字段类型信息"""
instance = JSONSchemaMessageInstance(typed_data, typed_schema)
type_info = instance.fields.get_sub_type_info("integer_field")
assert type_info is not None
assert type_info.standard_type.value == "integer"
assert type_info.current_value == 42
# 检查数值约束
assert type_info.has_constraint(ConstraintType.MIN_VALUE)
assert type_info.get_constraint_value(ConstraintType.MIN_VALUE) == 0
assert type_info.has_constraint(ConstraintType.MAX_VALUE)
assert type_info.get_constraint_value(ConstraintType.MAX_VALUE) == 100
assert type_info.has_constraint(ConstraintType.REQUIRED)
def test_array_field_type_info(self, typed_schema, typed_data):
"""测试数组字段类型信息"""
instance = JSONSchemaMessageInstance(typed_data, typed_schema)
type_info = instance.fields.get_sub_type_info("array_field")
assert type_info is not None
assert type_info.is_array is True
assert type_info.current_value == ["item1", "item2"]
# 检查数组约束
assert type_info.has_constraint(ConstraintType.MIN_ITEMS)
assert type_info.get_constraint_value(ConstraintType.MIN_ITEMS) == 1
assert type_info.has_constraint(ConstraintType.MAX_ITEMS)
assert type_info.get_constraint_value(ConstraintType.MAX_ITEMS) == 5
# 检查元素类型信息
assert type_info.element_type_info is not None
assert type_info.element_type_info.standard_type.value == "string"
def test_object_field_type_info(self, typed_schema, typed_data):
"""测试对象字段类型信息"""
instance = JSONSchemaMessageInstance(typed_data, typed_schema)
type_info = instance.fields.get_sub_type_info("object_field")
assert type_info is not None
assert type_info.is_object is True
assert type_info.current_value == {"nested_string": "nested_value"}
# 检查对象字段定义
assert len(type_info.object_fields) > 0
assert "nested_string" in type_info.object_fields
nested_field_info = type_info.object_fields["nested_string"]
assert nested_field_info.standard_type.value == "string"
def test_enum_field_type_info(self, typed_schema, typed_data):
"""测试枚举字段类型信息"""
instance = JSONSchemaMessageInstance(typed_data, typed_schema)
type_info = instance.fields.get_sub_type_info("enum_field")
assert type_info is not None
assert type_info.has_constraint(ConstraintType.ENUM_VALUES)
enum_values = type_info.get_constraint_value(ConstraintType.ENUM_VALUES)
assert enum_values == ["option1", "option2", "option3"]
def test_format_field_type_info(self, typed_schema, typed_data):
"""测试格式字段类型信息"""
instance = JSONSchemaMessageInstance(typed_data, typed_schema)
type_info = instance.fields.get_sub_type_info("format_field")
assert type_info is not None
assert type_info.has_constraint(ConstraintType.FORMAT)
format_value = type_info.get_constraint_value(ConstraintType.FORMAT)
assert format_value == "email"
class TestJSONSchemaInstanceJSONSchema:
"""JSONSchemaMessageInstance 自身的 JSON Schema 生成测试"""
def test_get_json_schema_simple(self):
"""测试简单数据的 JSON Schema 生成"""
schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "count": {"type": "integer"}},
}
data = {"name": "test", "count": 5}
instance = JSONSchemaMessageInstance(data, schema)
generated_schema = instance.get_json_schema()
assert generated_schema["type"] == "object"
assert "properties" in generated_schema
assert "name" in generated_schema["properties"]
assert "count" in generated_schema["properties"]
assert generated_schema["title"] == "JSONSchemaMessageInstance Schema"
def test_get_json_schema_with_constraints(self):
"""测试包含约束的 JSON Schema 生成"""
schema = {
"type": "object",
"properties": {
"email": {"type": "string", "format": "email", "minLength": 5},
"age": {"type": "integer", "minimum": 0, "maximum": 120},
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 1},
},
"required": ["email"],
}
data = {"email": "test@example.com", "age": 25, "tags": ["tag1", "tag2"]}
instance = JSONSchemaMessageInstance(data, schema)
generated_schema = instance.get_json_schema()
# 检查约束是否保留在生成的 schema 中
properties = generated_schema["properties"]
# email 字段约束
email_prop = properties["email"]
assert email_prop["type"] == "string"
# age 字段约束
age_prop = properties["age"]
assert age_prop["type"] == "integer"
# tags 数组约束
tags_prop = properties["tags"]
assert tags_prop["type"] == "array"
def test_get_json_schema_nested_objects(self):
"""测试嵌套对象的 JSON Schema 生成"""
schema = {
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"id": {"type": "string"},
"settings": {
"type": "object",
"properties": {"theme": {"type": "string"}},
},
},
}
},
}
data = {"user": {"id": "user123", "settings": {"theme": "dark"}}}
instance = JSONSchemaMessageInstance(data, schema)
generated_schema = instance.get_json_schema()
assert "user" in generated_schema["properties"]
user_prop = generated_schema["properties"]["user"]
assert user_prop["type"] == "object"
class TestJSONSchemaValidation:
"""JSON Schema 验证功能测试"""
def test_constraint_validation(self):
"""测试约束验证"""
schema = {
"type": "object",
"properties": {
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"name": {"type": "string", "minLength": 2, "maxLength": 50},
},
"required": ["name"],
}
# 有效数据
valid_data = {"name": "John", "age": 30}
valid_instance = JSONSchemaMessageInstance(valid_data, schema)
assert len(valid_instance._validation_errors) == 0
# 无效数据 - 年龄超出范围
invalid_data1 = {"name": "John", "age": 200}
invalid_instance1 = JSONSchemaMessageInstance(invalid_data1, schema)
assert len(invalid_instance1._validation_errors) > 0
# 无效数据 - 缺少必需字段
invalid_data2 = {"age": 30}
invalid_instance2 = JSONSchemaMessageInstance(invalid_data2, schema)
assert len(invalid_instance2._validation_errors) > 0
def test_type_validation(self):
"""测试类型验证"""
schema = {
"type": "object",
"properties": {
"count": {"type": "integer"},
"active": {"type": "boolean"},
"items": {"type": "array", "items": {"type": "string"}},
},
}
# 类型正确的数据
valid_data = {"count": 42, "active": True, "items": ["a", "b", "c"]}
valid_instance = JSONSchemaMessageInstance(valid_data, schema)
assert len(valid_instance._validation_errors) == 0
# 类型错误的数据
invalid_data = {
"count": "not_integer",
"active": "not_boolean",
"items": "not_array",
}
invalid_instance = JSONSchemaMessageInstance(invalid_data, schema)
assert len(invalid_instance._validation_errors) > 0
# 运行测试的便捷函数
def run_json_schema_tests():
"""运行 JSON Schema 相关测试"""
import subprocess
result = subprocess.run(
[
"python",
"-m",
"pytest",
"tests/test_json_schema_instance.py",
"-v",
"--tb=short",
],
capture_output=True,
text=True,
)
print(result.stdout)
if result.stderr:
print("STDERR:", result.stderr)
return result.returncode == 0
if __name__ == "__main__":
# 直接运行测试
run_json_schema_tests()

325
tests/test_ros2_instance.py Normal file
View File

@@ -0,0 +1,325 @@
import array
import os
import sys
import pytest
from msgcenterpy import TypeConverter
# Add project path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# ROS2 dependency check
try:
from geometry_msgs.msg import Point, Pose
from std_msgs.msg import Float64MultiArray, String
# Only import ROS2MessageInstance when ROS2 message packages are available
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
HAS_ROS2 = True
except ImportError:
HAS_ROS2 = False
from msgcenterpy.core.types import MessageType
class TestROS2MessageInstance:
"""ROS2MessageInstance test class"""
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_basic_creation_string_message(self):
"""Test basic String message creation"""
# Create String message
string_msg = String()
string_msg.data = "Hello ROS2"
# Create ROS2MessageInstance
ros2_inst = ROS2MessageInstance(string_msg)
assert ros2_inst.message_type == MessageType.ROS2
assert ros2_inst.inner_data is string_msg
assert ros2_inst.ros_msg_cls == String
assert ros2_inst.ros_msg_cls_namespace == "std_msgs/msg/String"
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_basic_creation_float_array(self):
"""Test Float64MultiArray message creation"""
# Create Float64MultiArray message
array_msg = Float64MultiArray()
array_msg.data = [1.1, 2.2, 3.3, 4.4, 5.5]
# Create ROS2MessageInstance
ros2_inst = ROS2MessageInstance(array_msg)
assert ros2_inst.message_type == MessageType.ROS2
assert ros2_inst.inner_data is array_msg
assert ros2_inst.ros_msg_cls == Float64MultiArray
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_simple_field_assignment(self):
"""Test simple field assignment - based on __main__ test1"""
# Create String message
string_msg = String()
ros2_inst = ROS2MessageInstance(string_msg)
# Initial state check
assert string_msg.data == ""
# Test field assignment
ros2_inst.data = "test_value" # Assignment through field accessor
assert string_msg.data == "test_value"
assert ros2_inst.inner_data.data == "test_value"
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_nested_field_assignment(self):
"""测试嵌套字段赋值 - 基于 __main__ 测试2,3"""
# Create Pose message
pose_msg = Pose()
ros2_inst = ROS2MessageInstance(pose_msg)
# 测试嵌套字段赋值
ros2_inst.position.x = 1.5
ros2_inst.position.y = 2.5
ros2_inst.position.z = 3.5
assert pose_msg.position.x == 1.5
assert pose_msg.position.y == 2.5
assert pose_msg.position.z == 3.5
# 测试整个对象赋值
new_position = Point(x=10.0, y=20.0, z=30.0)
ros2_inst.position = new_position
assert pose_msg.position.x == 10.0
assert pose_msg.position.y == 20.0
assert pose_msg.position.z == 30.0
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_export_to_envelope(self):
"""测试导出信封功能 - 基于 __main__ 测试6"""
# Create and setup String message
string_msg = String()
string_msg.data = "test_envelope_data"
ros2_inst = ROS2MessageInstance(string_msg)
# 导出信封
envelope = ros2_inst.export_to_envelope()
# 验证信封结构
assert "content" in envelope
assert "metadata" in envelope
assert envelope["content"]["data"] == "test_envelope_data"
# 验证元数据
metadata = envelope["metadata"]
assert metadata["current_format"] == "ros2"
assert "properties" in metadata
properties = metadata["properties"]
assert "ros_msg_cls_namespace" in properties
assert "ros_msg_cls_path" in properties
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_get_python_dict(self):
"""测试获取 Python 字典"""
# Create Float64MultiArray message
array_msg = Float64MultiArray()
array_msg.data = [1.0, 2.0, 3.0]
ros2_inst = ROS2MessageInstance(array_msg)
# 获取 Python 字典
python_dict = ros2_inst.get_python_dict()
assert isinstance(python_dict, dict)
assert "data" in python_dict
assert python_dict["data"] == [1.0, 2.0, 3.0]
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_set_python_dict(self):
"""测试设置 Python 字典"""
# Create String message
string_msg = String()
ros2_inst = ROS2MessageInstance(string_msg)
# 设置字典数据
new_data = {"data": "updated_value"}
result = ros2_inst.set_python_dict(new_data)
assert result is True
assert string_msg.data == "updated_value"
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_field_type_info_extraction(self):
"""测试字段类型信息提取"""
# Create Float64MultiArray message
array_msg = Float64MultiArray()
array_msg.data = [1.0, 2.0, 3.0]
ros2_inst = ROS2MessageInstance(array_msg)
# 获取字段类型信息
type_info = ros2_inst.fields.get_sub_type_info("data")
assert type_info is not None
assert type_info.field_name == "data"
assert type_info.is_array is True
assert type_info.python_type == array.array
assert type_info.python_value_from_standard_type == [1.0, 2.0, 3.0]
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_obtain_ros_cls_from_string(self):
"""测试从字符串获取 ROS 类"""
# 测试 namespace 格式
ros_cls_ns = ROS2MessageInstance.obtain_ros_cls_from_str("std_msgs/msg/String")
assert ros_cls_ns == String
# 测试模块路径格式
ros_cls_path = ROS2MessageInstance.obtain_ros_cls_from_str("std_msgs.msg._string.String")
assert ros_cls_path == String
# 测试直接传入类
ros_cls_direct = ROS2MessageInstance.obtain_ros_cls_from_str(String)
assert ros_cls_direct == String
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_ros_msg_cls_properties(self):
"""测试 ROS 消息类属性"""
string_msg = String()
ros2_inst = ROS2MessageInstance(string_msg)
# 测试类路径属性
cls_path = ros2_inst.ros_msg_cls_path
assert "std_msgs.msg" in cls_path
assert "String" in cls_path
# 测试命名空间属性
namespace = ros2_inst.ros_msg_cls_namespace
assert namespace == "std_msgs/msg/String"
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_import_from_envelope(self):
"""测试从信封导入"""
# Create original message
original_msg = String()
original_msg.data = "envelope_test"
original_inst = ROS2MessageInstance(original_msg)
# 导出信封
envelope = original_inst.export_to_envelope()
# 从信封导入新实例
new_inst = ROS2MessageInstance.import_from_envelope(envelope)
assert isinstance(new_inst, ROS2MessageInstance)
assert new_inst.inner_data.data == "envelope_test"
assert new_inst.ros_msg_cls == String
class TestROS2MessageInstanceJSONSchema:
"""ROS2MessageInstance JSON Schema 生成测试"""
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_get_json_schema_string_message(self):
"""测试 String 消息的 JSON Schema 生成"""
string_msg = String()
string_msg.data = "test_schema"
ros2_inst = ROS2MessageInstance(string_msg)
# 生成 JSON Schema
schema = ros2_inst.get_json_schema()
assert schema["type"] == "object"
assert "properties" in schema
assert "data" in schema["properties"]
assert schema["properties"]["data"]["type"] == "string"
assert schema["title"] == "ROS2MessageInstance Schema"
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_get_json_schema_float_array(self):
"""测试 Float64MultiArray 的 JSON Schema 生成"""
array_msg = Float64MultiArray()
array_msg.data = [1.1, 2.2, 3.3]
ros2_inst = ROS2MessageInstance(array_msg)
# 生成 JSON Schema
schema = ros2_inst.get_json_schema()
assert schema["type"] == "object"
assert "properties" in schema
assert "data" in schema["properties"]
# 检查数组类型
data_prop = schema["properties"]["data"]
assert data_prop["type"] == "array"
assert "items" in data_prop
assert data_prop["items"]["type"] == "number"
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_get_json_schema_pose_message(self):
"""测试复杂 Pose 消息的 JSON Schema 生成"""
pose_msg = Pose()
pose_msg.position.x = 1.0
pose_msg.position.y = 2.0
pose_msg.position.z = 3.0
ros2_inst = ROS2MessageInstance(pose_msg)
# 生成 JSON Schema
schema = ros2_inst.get_json_schema()
assert schema["type"] == "object"
assert "properties" in schema
# 检查嵌套对象
properties = schema["properties"]
assert "position" in properties
assert "orientation" in properties
# 验证对象类型
position_prop = properties["position"]
assert position_prop["type"] == "object"
@pytest.mark.skipif(not HAS_ROS2, reason="ROS2 dependencies not available")
def test_json_schema_constraint_extraction(self):
"""测试约束条件提取"""
array_msg = Float64MultiArray()
array_msg.data = [1.0, 2.0, 3.0, 4.0, 5.0]
ros2_inst = ROS2MessageInstance(array_msg)
# 获取字段类型信息检查约束
type_info = ros2_inst.fields.get_sub_type_info("data")
assert type_info is not None
assert type_info.is_array is True
# 生成 Schema 并检查约束是否转换
schema = ros2_inst.get_json_schema()
data_prop = schema["properties"]["data"]
assert data_prop["type"] == "array"
# 运行测试的便捷函数
def run_ros2_tests():
"""运行 ROS2 相关测试"""
if not HAS_ROS2:
print("❌ ROS2 dependencies not available, skipping tests")
return False
import subprocess
result = subprocess.run(
["python", "-m", "pytest", "tests/test_ros2_instance.py", "-v", "--tb=short"],
capture_output=True,
text=True,
)
print(result.stdout)
if result.stderr:
print("STDERR:", result.stderr)
return result.returncode == 0
if __name__ == "__main__":
# 直接运行测试
run_ros2_tests()

View File

@@ -0,0 +1,384 @@
import os
import sys
import pytest
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# 依赖检查
try:
from geometry_msgs.msg import Point, Pose, Vector3
from std_msgs.msg import Bool, Float64MultiArray, Int32, String
# 只有在 ROS2 消息包可用时才导入 ROS2MessageInstance
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
HAS_ROS2 = True
except ImportError:
HAS_ROS2 = False
import jsonschema
from msgcenterpy.core.types import MessageType
from msgcenterpy.instances.json_schema_instance import JSONSchemaMessageInstance
class TestROS2ToJSONSchemaConversion:
"""ROS2 转 JSON Schema 转换测试"""
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_string_message_to_json_schema(self):
"""测试 String 消息转 JSON Schema"""
# 创建 ROS2 String 消息
string_msg = String()
string_msg.data = "Hello JSON Schema"
ros2_inst = ROS2MessageInstance(string_msg)
# 生成 JSON Schema
schema = ros2_inst.get_json_schema()
# 验证 Schema 结构
assert schema["type"] == "object"
assert "properties" in schema
assert "data" in schema["properties"]
assert schema["properties"]["data"]["type"] == "string"
assert schema["title"] == "ROS2MessageInstance Schema"
assert "ros2" in schema["description"]
# 验证与原始数据的一致性
ros2_dict = ros2_inst.get_python_dict()
assert "data" in ros2_dict
assert ros2_dict["data"] == "Hello JSON Schema"
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_float_array_to_json_schema(self):
"""测试 Float64MultiArray 转 JSON Schema"""
# 创建 Float64MultiArray 消息
array_msg = Float64MultiArray()
array_msg.data = [1.1, 2.2, 3.3, 4.4, 5.5]
ros2_inst = ROS2MessageInstance(array_msg)
# 生成 JSON Schema
schema = ros2_inst.get_json_schema()
# 验证数组字段的 Schema
assert "data" in schema["properties"]
data_prop = schema["properties"]["data"]
assert data_prop["type"] == "array"
assert "items" in data_prop
assert data_prop["items"]["type"] == "number"
# 验证约束条件
assert "minItems" in data_prop or "maxItems" in data_prop or data_prop["type"] == "array"
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_pose_message_to_json_schema(self):
"""测试复杂 Pose 消息转 JSON Schema"""
# 创建 Pose 消息
pose_msg = Pose()
pose_msg.position.x = 1.0
pose_msg.position.y = 2.0
pose_msg.position.z = 3.0
pose_msg.orientation.w = 1.0
ros2_inst = ROS2MessageInstance(pose_msg)
# 生成 JSON Schema
schema = ros2_inst.get_json_schema()
# 验证嵌套对象结构
properties = schema["properties"]
assert "position" in properties
assert "orientation" in properties
# 验证对象类型
position_prop = properties["position"]
assert position_prop["type"] == "object"
orientation_prop = properties["orientation"]
assert orientation_prop["type"] == "object"
class TestROS2ToJSONSchemaInstanceConversion:
"""ROS2 转 JSONSchemaMessageInstance 转换测试"""
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_ros2_to_json_schema_instance_string(self):
"""测试 ROS2 String 转 JSONSchemaMessageInstance"""
# 创建 ROS2 实例
string_msg = String()
string_msg.data = "Test conversion"
ros2_inst = ROS2MessageInstance(string_msg)
# 转换为 JSONSchemaMessageInstance
json_schema_inst = ros2_inst.to_json_schema()
# 验证转换结果
assert isinstance(json_schema_inst, JSONSchemaMessageInstance)
assert json_schema_inst.message_type == MessageType.JSON_SCHEMA
# 验证数据一致性
original_data = ros2_inst.get_python_dict()
converted_data = json_schema_inst.get_python_dict()
assert original_data == converted_data
# 验证 Schema 存在
schema = json_schema_inst.json_schema
assert schema is not None
assert schema["type"] == "object"
assert "properties" in schema
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_ros2_to_json_schema_instance_array(self):
"""测试 ROS2 数组转 JSONSchemaMessageInstance"""
# 创建 ROS2 数组实例
array_msg = Float64MultiArray()
array_msg.data = [10.5, 20.3, 30.7]
ros2_inst = ROS2MessageInstance(array_msg)
# 转换为 JSONSchemaMessageInstance
json_schema_inst = ros2_inst.to_json_schema()
# 验证转换结果
assert isinstance(json_schema_inst, JSONSchemaMessageInstance)
# 验证数据一致性
original_data = ros2_inst.get_python_dict()
converted_data = json_schema_inst.get_python_dict()
assert original_data == converted_data
assert converted_data["data"] == [10.5, 20.3, 30.7]
# 验证 Schema 的数组类型
schema = json_schema_inst.json_schema
if "data" in schema["properties"]:
data_prop = schema["properties"]["data"]
assert data_prop["type"] == "array"
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_ros2_to_json_schema_instance_pose(self):
"""测试 ROS2 Pose 转 JSONSchemaMessageInstance"""
# 创建复杂的 Pose 消息
pose_msg = Pose()
pose_msg.position.x = 5.0
pose_msg.position.y = 10.0
pose_msg.position.z = 15.0
pose_msg.orientation.x = 0.0
pose_msg.orientation.y = 0.0
pose_msg.orientation.z = 0.0
pose_msg.orientation.w = 1.0
ros2_inst = ROS2MessageInstance(pose_msg)
# 转换为 JSONSchemaMessageInstance
json_schema_inst = ros2_inst.to_json_schema()
# 验证转换结果
assert isinstance(json_schema_inst, JSONSchemaMessageInstance)
# 验证嵌套数据一致性
original_data = ros2_inst.get_python_dict()
converted_data = json_schema_inst.get_python_dict()
assert original_data == converted_data
# 验证嵌套结构
assert "position" in converted_data
assert "orientation" in converted_data
assert converted_data["position"]["x"] == 5.0
assert converted_data["position"]["y"] == 10.0
assert converted_data["position"]["z"] == 15.0
class TestConversionConsistency:
"""转换一致性测试"""
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_schema_data_consistency(self):
"""测试 Schema 与数据的一致性"""
# 创建多种类型的数据
test_cases = []
# String 消息
string_msg = String()
string_msg.data = "consistency_test"
test_cases.append(("String", ROS2MessageInstance(string_msg)))
# Float64MultiArray 消息
array_msg = Float64MultiArray()
array_msg.data = [1.0, 2.0, 3.0]
test_cases.append(("Float64MultiArray", ROS2MessageInstance(array_msg)))
for test_name, ros2_inst in test_cases:
# 生成 Schema
schema = ros2_inst.get_json_schema()
original_data = ros2_inst.get_python_dict()
# 验证 Schema 属性与数据字段一致
schema_props = set(schema["properties"].keys())
data_keys = set(original_data.keys())
assert schema_props == data_keys, f"{test_name}: Schema properties and data keys don't match"
# 验证每个字段的类型一致性
for field_name, field_value in original_data.items():
if field_name in schema["properties"]:
prop_schema = schema["properties"][field_name]
# 基本类型检查
if isinstance(field_value, str):
assert prop_schema["type"] == "string", f"{test_name}.{field_name}: Type mismatch"
elif isinstance(field_value, bool):
assert prop_schema["type"] == "boolean", f"{test_name}.{field_name}: Type mismatch"
elif isinstance(field_value, int):
assert prop_schema["type"] == "integer", f"{test_name}.{field_name}: Type mismatch"
elif isinstance(field_value, float):
assert prop_schema["type"] == "number", f"{test_name}.{field_name}: Type mismatch"
elif isinstance(field_value, list):
assert prop_schema["type"] == "array", f"{test_name}.{field_name}: Type mismatch"
elif isinstance(field_value, dict):
assert prop_schema["type"] == "object", f"{test_name}.{field_name}: Type mismatch"
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_conversion_roundtrip_data(self):
"""测试转换往返的数据一致性"""
# 创建原始 ROS2 消息
string_msg = String()
string_msg.data = "roundtrip_test"
original_ros2_inst = ROS2MessageInstance(string_msg)
original_data = original_ros2_inst.get_python_dict()
# ROS2 -> JSONSchema
json_schema_inst = original_ros2_inst.to_json_schema()
converted_data = json_schema_inst.get_python_dict()
# 验证数据在转换过程中保持一致
assert original_data == converted_data
# 验证 Schema 的有效性
schema = json_schema_inst.json_schema
assert schema is not None
# 使用 jsonschema 库验证数据符合生成的 Schema
try:
jsonschema.validate(converted_data, schema)
except jsonschema.ValidationError as e:
pytest.fail(f"Generated data doesn't match generated schema: {e}")
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_multiple_message_types_conversion(self):
"""测试多种消息类型的转换"""
test_messages = []
# String
string_msg = String()
string_msg.data = "multi_test_string"
test_messages.append(("String", string_msg))
# Float64MultiArray
array_msg = Float64MultiArray()
array_msg.data = [1.5, 2.5, 3.5]
test_messages.append(("Float64MultiArray", array_msg))
# Point
point_msg = Point()
point_msg.x = 1.0
point_msg.y = 2.0
point_msg.z = 3.0
test_messages.append(("Point", point_msg))
for msg_type_name, msg in test_messages:
# 创建 ROS2 实例
ros2_inst = ROS2MessageInstance(msg)
# 转换为 JSONSchema 实例
json_schema_inst = ros2_inst.to_json_schema()
# 验证转换成功
assert isinstance(json_schema_inst, JSONSchemaMessageInstance)
assert json_schema_inst.message_type == MessageType.JSON_SCHEMA
# 验证数据一致性
original_data = ros2_inst.get_python_dict()
converted_data = json_schema_inst.get_python_dict()
assert original_data == converted_data, f"{msg_type_name}: Data inconsistency after conversion"
# 验证 Schema 生成
schema = json_schema_inst.json_schema
assert schema is not None
assert schema["type"] == "object"
assert len(schema["properties"]) > 0, f"{msg_type_name}: No properties in generated schema"
class TestConversionErrorHandling:
"""转换错误处理测试"""
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_empty_message_conversion(self):
"""测试空消息的转换"""
# 创建空的 String 消息
empty_string = String()
ros2_inst = ROS2MessageInstance(empty_string)
# 转换应该成功,即使数据为空
json_schema_inst = ros2_inst.to_json_schema()
assert isinstance(json_schema_inst, JSONSchemaMessageInstance)
# 验证 Schema 仍然有效
schema = json_schema_inst.json_schema
assert schema["type"] == "object"
assert "properties" in schema
@pytest.mark.skipif(not HAS_ROS2, reason="Missing dependencies")
def test_large_data_conversion(self):
"""测试大数据量的转换"""
# 创建包含大量数据的数组消息
large_array = Float64MultiArray()
large_array.data = [float(i) for i in range(1000)] # 1000 个浮点数
ros2_inst = ROS2MessageInstance(large_array)
# 转换应该能处理大数据量
json_schema_inst = ros2_inst.to_json_schema()
assert isinstance(json_schema_inst, JSONSchemaMessageInstance)
# 验证数据完整性
original_data = ros2_inst.get_python_dict()
converted_data = json_schema_inst.get_python_dict()
assert len(original_data["data"]) == 1000
assert len(converted_data["data"]) == 1000
assert original_data == converted_data
# 运行测试的便捷函数
def run_conversion_tests():
"""运行转换测试"""
if not HAS_ROS2:
print("❌ Required dependencies not available, skipping tests")
print(f" ROS2: {HAS_ROS2}")
return False
import subprocess
result = subprocess.run(
[
"python",
"-m",
"pytest",
"tests/test_ros_to_json_schema_conversion.py",
"-v",
"--tb=short",
],
capture_output=True,
text=True,
)
print(result.stdout)
if result.stderr:
print("STDERR:", result.stderr)
return result.returncode == 0
if __name__ == "__main__":
# 直接运行测试
run_conversion_tests()