From 94f0c112e51dd573e21b922c89cc1fe0b7d9c2ad Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:39:44 +0800 Subject: [PATCH] init version --- .bumpversion.cfg | 10 + .github/dependabot.yml | 37 ++ .github/workflows/ci.yml | 224 +++++++++ .github/workflows/docs.yml | 69 +++ .github/workflows/publish.yml | 258 ++++++++++ .gitignore | 57 +++ .pre-commit-config.yaml | 81 +++ LICENSE | 199 ++++++++ MANIFEST.in | 6 + README.md | 236 +++++++++ docs/Makefile | 64 +++ docs/api/core.rst | 60 +++ docs/conf.py | 135 +++++ docs/development/contributing.rst | 160 ++++++ docs/examples/basic_usage.rst | 225 +++++++++ docs/index.rst | 176 +++++++ docs/installation.rst | 122 +++++ docs/quickstart.rst | 138 ++++++ docs/requirements.txt | 14 + msgcenterpy/__init__.py | 97 ++++ msgcenterpy/core/__init__.py | 0 msgcenterpy/core/envelope.py | 54 ++ msgcenterpy/core/field_accessor.py | 406 +++++++++++++++ msgcenterpy/core/message_center.py | 69 +++ msgcenterpy/core/message_instance.py | 193 ++++++++ msgcenterpy/core/type_converter.py | 411 ++++++++++++++++ msgcenterpy/core/type_info.py | 400 +++++++++++++++ msgcenterpy/core/types.py | 25 + msgcenterpy/instances/__init__.py | 0 msgcenterpy/instances/json_schema_instance.py | 303 ++++++++++++ msgcenterpy/instances/ros2_instance.py | 242 +++++++++ msgcenterpy/utils/__init__.py | 0 msgcenterpy/utils/decorator.py | 29 ++ pyproject.toml | 106 ++++ scripts/setup-dev.ps1 | 59 +++ scripts/setup-dev.sh | 46 ++ setup-dev.ps1 | 69 +++ setup-dev.sh | 52 ++ tests/test_json_schema_instance.py | 463 ++++++++++++++++++ tests/test_ros2_instance.py | 325 ++++++++++++ tests/test_ros_to_json_schema_conversion.py | 384 +++++++++++++++ 41 files changed, 6004 insertions(+) create mode 100644 .bumpversion.cfg create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 docs/Makefile create mode 100644 docs/api/core.rst create mode 100644 docs/conf.py create mode 100644 docs/development/contributing.rst create mode 100644 docs/examples/basic_usage.rst create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/quickstart.rst create mode 100644 docs/requirements.txt create mode 100644 msgcenterpy/__init__.py create mode 100644 msgcenterpy/core/__init__.py create mode 100644 msgcenterpy/core/envelope.py create mode 100644 msgcenterpy/core/field_accessor.py create mode 100644 msgcenterpy/core/message_center.py create mode 100644 msgcenterpy/core/message_instance.py create mode 100644 msgcenterpy/core/type_converter.py create mode 100644 msgcenterpy/core/type_info.py create mode 100644 msgcenterpy/core/types.py create mode 100644 msgcenterpy/instances/__init__.py create mode 100644 msgcenterpy/instances/json_schema_instance.py create mode 100644 msgcenterpy/instances/ros2_instance.py create mode 100644 msgcenterpy/utils/__init__.py create mode 100644 msgcenterpy/utils/decorator.py create mode 100644 pyproject.toml create mode 100644 scripts/setup-dev.ps1 create mode 100755 scripts/setup-dev.sh create mode 100644 setup-dev.ps1 create mode 100644 setup-dev.sh create mode 100644 tests/test_json_schema_instance.py create mode 100644 tests/test_ros2_instance.py create mode 100644 tests/test_ros_to_json_schema_conversion.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..192a9a0 --- /dev/null +++ b/.bumpversion.cfg @@ -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}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a9b2a40 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad61cb8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f91f110 --- /dev/null +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..fd6812d --- /dev/null +++ b/.github/workflows/publish.yml @@ -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!" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e81c88 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..83ead25 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6553aa --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0b0255f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +# Include important project files +include LICENSE +include README.md + +# Include all msgcenterpy source files +recursive-include msgcenterpy * diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1615b1 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# MsgCenterPy + +
+ +[![PyPI version](https://badge.fury.io/py/msgcenterpy.svg)](https://badge.fury.io/py/msgcenterpy) +[![Python versions](https://img.shields.io/pypi/pyversions/msgcenterpy.svg)](https://pypi.org/project/msgcenterpy/) +[![Build Status](https://github.com/ZGCA-Forge/MsgCenterPy/workflows/CI/badge.svg)](https://github.com/ZGCA-Forge/MsgCenterPy/actions) +[![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-brightgreen)](https://zgca-forge.github.io/MsgCenterPy/) + +[![GitHub stars](https://img.shields.io/github/stars/ZGCA-Forge/MsgCenterPy.svg?style=social&label=Star)](https://github.com/ZGCA-Forge/MsgCenterPy) +[![GitHub forks](https://img.shields.io/github/forks/ZGCA-Forge/MsgCenterPy.svg?style=social&label=Fork)](https://github.com/ZGCA-Forge/MsgCenterPy/fork) +[![GitHub issues](https://img.shields.io/github/issues/ZGCA-Forge/MsgCenterPy.svg)](https://github.com/ZGCA-Forge/MsgCenterPy/issues) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/ZGCA-Forge/MsgCenterPy/blob/main/LICENSE) + +
+ +--- + +## 🚀 概述 + +MsgCenterPy 是一个基于统一实例管理器架构的多格式消息转换系统,支持 **ROS2**、**Pydantic**、**Dataclass**、**JSON**、**Dict**、**YAML** 和 **JSON Schema** 之间的无缝互转。 + +### 支持的格式 + +| 格式 | 读取 | 写入 | JSON Schema | 类型约束 | +| ----------- | ------ | ------ | ----------- | -------- | +| ROS2 | ✅ | ✅ | ✅ | ✅ | +| JSON Schema | ✅ | ✅ | ✅ | ✅ | +| Pydantic | 开发中 | 开发中 | 开发中 | 开发中 | +| Dataclass | 开发中 | 开发中 | 开发中 | 开发中 | +| JSON | 开发中 | 开发中 | 开发中 | 开发中 | +| Dict | 开发中 | 开发中 | 开发中 | 开发中 | +| YAML | 开发中 | 开发中 | 开发中 | 开发中 | + +## 📦 安装 + +### 基础安装 + +```bash +pip install msgcenterpy +``` + +### 包含可选依赖 + +```bash +# 安装 ROS2 支持 +conda install + +# 安装开发工具 +pip install msgcenterpy[dev] + +# 安装文档工具 +pip install msgcenterpy[docs] + +# 安装所有依赖 +pip install msgcenterpy[all] +``` + +### 从源码安装 + +```bash +git clone https://github.com/ZGCA-Forge/MsgCenterPy.git +cd MsgCenterPy +pip install -e .[dev] +``` + +## 🔧 快速开始 + +### 基础使用 + +```python +from msgcenterpy import MessageInstance, MessageType + +# 从字典创建消息实例 +data = { + "name": "sensor_001", + "readings": [1.0, 2.0, 3.0], + "active": True +} +dict_instance = MessageInstance.create(MessageType.DICT, data) + +# 生成 JSON Schema +schema = dict_instance.get_json_schema() +print(schema) +``` + +### ROS2 消息转换 + +```python +from std_msgs.msg import String +from msgcenterpy.instances.ros2_instance import ROS2MessageInstance + +# 创建 ROS2 消息实例 +string_msg = String() +string_msg.data = "Hello ROS2" +ros2_instance = ROS2MessageInstance(string_msg) + +# 转换为 JSON Schema +json_schema_instance = ros2_instance.to_json_schema() + +# 获取生成的 Schema +schema = json_schema_instance.json_schema +print(schema) +``` + +### 字段访问和约束 + +```python +# 动态字段访问 +ros2_instance.data_field = "new_value" +print(ros2_instance.fields.get_field_info("data")) + +# 类型约束验证 +type_info = ros2_instance.fields.get_sub_type_info("data") +print(f"约束条件: {[c.type.value for c in type_info.constraints]}") +``` + +## 📖 文档 + +### 核心概念 + +- **MessageInstance**: 统一消息实例基类 +- **TypeInfo**: 详细的字段类型信息和约束 +- **FieldAccessor**: 统一字段访问接口 +- **MessageCenter**: 消息类型管理和转换中心 + +### API 参考 + +详细的 API 文档请访问:[https://zgca-forge.github.io/MsgCenterPy/](https://zgca-forge.github.io/MsgCenterPy/) + +### 示例代码 + +更多示例请查看 [`examples/`](examples/) 目录: + +- [ROS2 消息转换示例](examples/ros2_example.py) +- [JSON Schema 生成示例](examples/json_schema_example.py) +- [类型约束示例](examples/type_constraints_example.py) + +## 🧪 测试 + +### 运行测试 + +```bash +# 运行所有测试 +python -m pytest + +# 运行特定测试套件 +python run_all_tests.py --type json_schema +``` + +```bash +# 生成覆盖率报告 +pytest --cov=msgcenterpy --cov-report=html +``` + +## 🛠️ 开发 + +### 开发环境设置 + +```bash +git clone https://github.com/ZGCA-Forge/MsgCenterPy.git +cd MsgCenterPy +pip install -e .[dev] +pre-commit install +``` + +### 代码质量 + +```bash +# 代码格式化 +black msgcenterpy tests +isort msgcenterpy tests + +# 类型检查 +mypy msgcenterpy + +# 运行测试 +pytest +``` + +### 贡献指南 + +1. Fork 项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交变更 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 创建 Pull Request + +详细贡献指南请查看 [CONTRIBUTING.md](CONTRIBUTING.md) + +## 📊 开发路线图 + +- [x] ✅ ROS2 消息支持 +- [x] ✅ JSON Schema 生成和验证 +- [x] ✅ 统一字段访问器 +- [x] ✅ 类型约束系统 +- [ ] 🔄 Pydantic 集成 +- [ ] 🔄 Dataclass 支持 +- [ ] 🔄 YAML 格式支持 +- [ ] 🔄 性能优化 +- [ ] 🔄 插件系统 + +## 🤝 社区 + +### 支持渠道 + +- 💬 讨论: [GitHub Discussions](https://github.com/ZGCA-Forge/MsgCenterPy/discussions) +- 🐛 问题: [GitHub Issues](https://github.com/ZGCA-Forge/MsgCenterPy/issues) +- 📖 文档: [GitHub Pages](https://zgca-forge.github.io/MsgCenterPy/) + +### 贡献者 + +感谢所有为 MsgCenterPy 做出贡献的开发者! + +[![Contributors](https://contrib.rocks/image?repo=ZGCA-Forge/MsgCenterPy)](https://github.com/ZGCA-Forge/MsgCenterPy/graphs/contributors) + +## 📄 许可证 + +本项目基于 Apache-2.0 许可证开源 - 查看 [LICENSE](LICENSE) 文件了解详情。 + +## 🙏 致谢 + +- [ROS2](https://ros.org/) - 机器人操作系统 +- [Pydantic](https://pydantic-docs.helpmanual.io/) - 数据验证库 +- [pytest](https://pytest.org/) - 测试框架 +- [jsonschema](https://python-jsonschema.readthedocs.io/) - JSON Schema 验证 + +--- + +
+ +**[⭐ 给个 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 + +
diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..9e9215d --- /dev/null +++ b/docs/Makefile @@ -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." diff --git a/docs/api/core.rst b/docs/api/core.rst new file mode 100644 index 0000000..f2787aa --- /dev/null +++ b/docs/api/core.rst @@ -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: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..f8c1ac1 --- /dev/null +++ b/docs/conf.py @@ -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 +} diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst new file mode 100644 index 0000000..a18bf83 --- /dev/null +++ b/docs/development/contributing.rst @@ -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 `_ on GitHub. + +Quick Links +----------- + +- `GitHub Repository `_ +- `Issue Tracker `_ +- `Pull Requests `_ +- `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 `_ +- Start a `Discussion `_ +- Contact the maintainers diff --git a/docs/examples/basic_usage.rst b/docs/examples/basic_usage.rst new file mode 100644 index 0000000..20f9300 --- /dev/null +++ b/docs/examples/basic_usage.rst @@ -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']}") diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..c0a4a12 --- /dev/null +++ b/docs/index.rst @@ -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 `_ file for details. diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..c3f8fd0 --- /dev/null +++ b/docs/installation.rst @@ -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 `_ page. diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..0358981 --- /dev/null +++ b/docs/quickstart.rst @@ -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 diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..a13df35 --- /dev/null +++ b/docs/requirements.txt @@ -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 diff --git a/msgcenterpy/__init__.py b/msgcenterpy/__init__.py new file mode 100644 index 0000000..a092062 --- /dev/null +++ b/msgcenterpy/__init__.py @@ -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 diff --git a/msgcenterpy/core/__init__.py b/msgcenterpy/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msgcenterpy/core/envelope.py b/msgcenterpy/core/envelope.py new file mode 100644 index 0000000..39d77c7 --- /dev/null +++ b/msgcenterpy/core/envelope.py @@ -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 diff --git a/msgcenterpy/core/field_accessor.py b/msgcenterpy/core/field_accessor.py new file mode 100644 index 0000000..e40ce41 --- /dev/null +++ b/msgcenterpy/core/field_accessor.py @@ -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) diff --git a/msgcenterpy/core/message_center.py b/msgcenterpy/core/message_center.py new file mode 100644 index 0000000..397c268 --- /dev/null +++ b/msgcenterpy/core/message_center.py @@ -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() diff --git a/msgcenterpy/core/message_instance.py b/msgcenterpy/core/message_instance.py new file mode 100644 index 0000000..8ecda1b --- /dev/null +++ b/msgcenterpy/core/message_instance.py @@ -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), + ) diff --git a/msgcenterpy/core/type_converter.py b/msgcenterpy/core/type_converter.py new file mode 100644 index 0000000..6d847b5 --- /dev/null +++ b/msgcenterpy/core/type_converter.py @@ -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) diff --git a/msgcenterpy/core/type_info.py b/msgcenterpy/core/type_info.py new file mode 100644 index 0000000..c97d1b3 --- /dev/null +++ b/msgcenterpy/core/type_info.py @@ -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) diff --git a/msgcenterpy/core/types.py b/msgcenterpy/core/types.py new file mode 100644 index 0000000..6487e2f --- /dev/null +++ b/msgcenterpy/core/types.py @@ -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 diff --git a/msgcenterpy/instances/__init__.py b/msgcenterpy/instances/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msgcenterpy/instances/json_schema_instance.py b/msgcenterpy/instances/json_schema_instance.py new file mode 100644 index 0000000..5b4c4cd --- /dev/null +++ b/msgcenterpy/instances/json_schema_instance.py @@ -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 diff --git a/msgcenterpy/instances/ros2_instance.py b/msgcenterpy/instances/ros2_instance.py new file mode 100644 index 0000000..2804b07 --- /dev/null +++ b/msgcenterpy/instances/ros2_instance.py @@ -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) diff --git a/msgcenterpy/utils/__init__.py b/msgcenterpy/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msgcenterpy/utils/decorator.py b/msgcenterpy/utils/decorator.py new file mode 100644 index 0000000..18542e7 --- /dev/null +++ b/msgcenterpy/utils/decorator.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d2750ca --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/scripts/setup-dev.ps1 b/scripts/setup-dev.ps1 new file mode 100644 index 0000000..45b819c --- /dev/null +++ b/scripts/setup-dev.ps1 @@ -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 diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh new file mode 100755 index 0000000..7d93ca2 --- /dev/null +++ b/scripts/setup-dev.sh @@ -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" diff --git a/setup-dev.ps1 b/setup-dev.ps1 new file mode 100644 index 0000000..dac7bdd --- /dev/null +++ b/setup-dev.ps1 @@ -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 diff --git a/setup-dev.sh b/setup-dev.sh new file mode 100644 index 0000000..18717e8 --- /dev/null +++ b/setup-dev.sh @@ -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" diff --git a/tests/test_json_schema_instance.py b/tests/test_json_schema_instance.py new file mode 100644 index 0000000..5bad6ae --- /dev/null +++ b/tests/test_json_schema_instance.py @@ -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() diff --git a/tests/test_ros2_instance.py b/tests/test_ros2_instance.py new file mode 100644 index 0000000..7da33ad --- /dev/null +++ b/tests/test_ros2_instance.py @@ -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() diff --git a/tests/test_ros_to_json_schema_conversion.py b/tests/test_ros_to_json_schema_conversion.py new file mode 100644 index 0000000..57aa14f --- /dev/null +++ b/tests/test_ros_to_json_schema_conversion.py @@ -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()