mirror of
https://github.com/ZGCA-Forge/MsgCenterPy.git
synced 2025-12-14 13:04:34 +00:00
init version
This commit is contained in:
10
.bumpversion.cfg
Normal file
10
.bumpversion.cfg
Normal 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
37
.github/dependabot.yml
vendored
Normal 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
224
.github/workflows/ci.yml
vendored
Normal 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
69
.github/workflows/docs.yml
vendored
Normal 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
258
.github/workflows/publish.yml
vendored
Normal 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
57
.gitignore
vendored
Normal 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
81
.pre-commit-config.yaml
Normal 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
199
LICENSE
Normal 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
6
MANIFEST.in
Normal 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
236
README.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# MsgCenterPy
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://badge.fury.io/py/msgcenterpy)
|
||||
[](https://pypi.org/project/msgcenterpy/)
|
||||
[](https://github.com/ZGCA-Forge/MsgCenterPy/actions)
|
||||
[](https://zgca-forge.github.io/MsgCenterPy/)
|
||||
|
||||
[](https://github.com/ZGCA-Forge/MsgCenterPy)
|
||||
[](https://github.com/ZGCA-Forge/MsgCenterPy/fork)
|
||||
[](https://github.com/ZGCA-Forge/MsgCenterPy/issues)
|
||||
[](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 做出贡献的开发者!
|
||||
|
||||
[](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
64
docs/Makefile
Normal 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
60
docs/api/core.rst
Normal 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
135
docs/conf.py
Normal 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
|
||||
}
|
||||
160
docs/development/contributing.rst
Normal file
160
docs/development/contributing.rst
Normal 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
|
||||
225
docs/examples/basic_usage.rst
Normal file
225
docs/examples/basic_usage.rst
Normal 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
176
docs/index.rst
Normal 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
122
docs/installation.rst
Normal 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
138
docs/quickstart.rst
Normal 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
14
docs/requirements.txt
Normal 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
97
msgcenterpy/__init__.py
Normal 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
|
||||
0
msgcenterpy/core/__init__.py
Normal file
0
msgcenterpy/core/__init__.py
Normal file
54
msgcenterpy/core/envelope.py
Normal file
54
msgcenterpy/core/envelope.py
Normal 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
|
||||
406
msgcenterpy/core/field_accessor.py
Normal file
406
msgcenterpy/core/field_accessor.py
Normal 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)
|
||||
69
msgcenterpy/core/message_center.py
Normal file
69
msgcenterpy/core/message_center.py
Normal 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()
|
||||
193
msgcenterpy/core/message_instance.py
Normal file
193
msgcenterpy/core/message_instance.py
Normal 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),
|
||||
)
|
||||
411
msgcenterpy/core/type_converter.py
Normal file
411
msgcenterpy/core/type_converter.py
Normal 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,
|
||||
# 整数类型(都映射到int,Python会自动处理范围)
|
||||
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",
|
||||
# 整数类型(都映射到int,Python会自动处理范围)
|
||||
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)
|
||||
400
msgcenterpy/core/type_info.py
Normal file
400
msgcenterpy/core/type_info.py
Normal 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
25
msgcenterpy/core/types.py
Normal 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
|
||||
0
msgcenterpy/instances/__init__.py
Normal file
0
msgcenterpy/instances/__init__.py
Normal file
303
msgcenterpy/instances/json_schema_instance.py
Normal file
303
msgcenterpy/instances/json_schema_instance.py
Normal 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
|
||||
242
msgcenterpy/instances/ros2_instance.py
Normal file
242
msgcenterpy/instances/ros2_instance.py
Normal 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)
|
||||
0
msgcenterpy/utils/__init__.py
Normal file
0
msgcenterpy/utils/__init__.py
Normal file
29
msgcenterpy/utils/decorator.py
Normal file
29
msgcenterpy/utils/decorator.py
Normal 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
106
pyproject.toml
Normal 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
59
scripts/setup-dev.ps1
Normal 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
46
scripts/setup-dev.sh
Executable 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
69
setup-dev.ps1
Normal 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
52
setup-dev.sh
Normal 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"
|
||||
463
tests/test_json_schema_instance.py
Normal file
463
tests/test_json_schema_instance.py
Normal 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
325
tests/test_ros2_instance.py
Normal 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()
|
||||
384
tests/test_ros_to_json_schema_conversion.py
Normal file
384
tests/test_ros_to_json_schema_conversion.py
Normal 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()
|
||||
Reference in New Issue
Block a user