Skip to content

Commit

Permalink
Add messages implementation for python (#165)
Browse files Browse the repository at this point in the history
* [python] Add messages implementation for python

* [python] Review fixes

* Fixup property type definitions
* Fixup property descriptions
  * Descriptions inlined where possible
  * Property descriptions are placed after properties per se
* Remove redundant double-quotes at type definitions
* Split enums and model templates
* Simplify gh-action test matrix
* Fixup empty project.toml settings

* Update python/pyproject.toml

Co-authored-by: Luke Hill <20105237+luke-hill@users.noreply.github.com>

* Update CHANGELOG.md

---------

Co-authored-by: Luke Hill <20105237+luke-hill@users.noreply.github.com>
Co-authored-by: David Goss <david@davidgoss.co>
  • Loading branch information
3 people authored Jan 29, 2025
1 parent f375dce commit 4ed7f02
Show file tree
Hide file tree
Showing 23 changed files with 2,033 additions and 1 deletion.
23 changes: 23 additions & 0 deletions .github/workflows/release-pypi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Release Python

on:
push:
branches: [release/*]

jobs:
release:
name: Release
runs-on: ubuntu-latest
environment: Release
permissions:
id-token: write
defaults:
run:
working-directory: python
steps:
- name: Checkout code
uses: actions/checkout@v4

- uses: cucumber/action-publish-pypi@v3.0.0
with:
working-directory: "python"
45 changes: 45 additions & 0 deletions .github/workflows/test-python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
name: test-python

on:
push:
branches:
- main
- renovate/**
pull_request:
branches:
- main
workflow_dispatch:

jobs:
build:

runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# Test latest python on Windows / macOS
- { os: 'windows-latest', python-version: '3.13' }
- { os: 'macos-latest', python-version: '3.13' }
os: ['ubuntu-latest']
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.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: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -U setuptools
pip install tox tox-gh-actions codecov
- name: Test with tox
working-directory: ./python
run: |
tox
- name: Gather codecov report
working-directory: ./python
run: |
codecov
38 changes: 38 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
---
files: ^python/
exclude: .*python/src/cucumber_messages/_messages\.py
repos:
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
args:
- "python/src"
- "python/tests"
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.14.0
hooks:
- id: pretty-format-toml
args: [--autofix]
- repo: https://github.com/asottile/pyupgrade
rev: v3.19.1
hooks:
- id: pyupgrade
args: ["--py39-plus"]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-certifi]
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Added
- [python] Added Python implementation ([#165](https://github.com/cucumber/messages/pull/165))

## [27.1.0] - 2025-01-28
### Added
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ schemas = \
./jsonschema/UndefinedParameterType.json \
./jsonschema/Envelope.json

languages = cpp go java javascript perl php ruby dotnet
languages = cpp dotnet go java javascript perl php python ruby

.DEFAULT_GOAL = help

Expand Down
1 change: 1 addition & 0 deletions codegen/codegen.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_relative 'generators/markdown'
require_relative 'generators/perl'
require_relative 'generators/php'
require_relative 'generators/python'
require_relative 'generators/ruby'
require_relative 'generators/typescript'

Expand Down
153 changes: 153 additions & 0 deletions codegen/generators/python.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# frozen_string_literal: true

module Generator
class Python < Base
def format_enum_value(value)
value.downcase.gsub(/[.\/+\s-]/, '_')
end

def get_sorted_properties(definition)
required_fields = definition['required'] || []
definition['properties'].sort_by do |name, *|
[required_fields.include?(name) ? 0 : 1, name]
end
end

def format_property(parent_type_name, property_name, property, required_fields)
snake_name = property_name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
.downcase

property_type = get_property_type(parent_type_name, property_name, property)
is_required = required_fields.include?(property_name)

property_description = if property['description'] && !property['description'].include?("\n")
" # #{property['description']}"
else
''
end
if is_required
"#{snake_name}: #{property_type}#{property_description}"
else
"#{snake_name}: Optional[#{property_type}] = None#{property_description}"
end
end

def get_property_type(parent_type_name, property_name, property)
type = type_for(parent_type_name, property_name, property)
type.match?(/\A[A-Z]/) ? class_name(type) : type
end

def array_type_for(type_name)
inner_type = if language_translations_for_data_types.values.include?(type_name)
type_name # Keep primitive types as is
else
class_name(type_name) # CamelCase for complex types
end
inner_type
end

def format_description(raw_description, indent_string: ' ')
return '""" """' if raw_description.nil?

lines = raw_description.split("\n").map { |line|
if line.strip.empty?
""
else
"#{indent_string}#{line.rstrip}"
end
}

%("""\n#{lines.join("\n")}\n#{indent_string}""")
end

def language_translations_for_data_types
{
'integer' => 'int',
'string' => 'str',
'boolean' => 'bool',
'array' => 'list'
}
end

private

def default_value(parent_type_name, property_name, property)
if property['type'] == 'string'
default_value_for_string(parent_type_name, property_name, property)
elsif property['type'] == 'integer'
'0'
elsif property['type'] == 'boolean'
'False'
elsif property['type'] == 'array'
'[]'
elsif property['$ref']
"#{class_name(type_for(parent_type_name, nil, property))}()"
else
'None'
end
end

def default_value_for_string(parent_type_name, property_name, property)
if property['enum']
enum_type_name = type_for(parent_type_name, property_name, property)
"#{class_name(enum_type_name)}.#{enum_constant(property['enum'][0])}"
else
'""'
end
end

def type_for(parent_type_name, property_name, property)
if property['$ref']
property_type_from_ref(property['$ref'])
elsif property['type']
property_type_from_type(parent_type_name, property_name, property, type: property['type'])
else
raise "Property #{property_name} did not define 'type' or '$ref'"
end
end

def property_type_from_type(parent_type_name, property_name, property, type:)
if type == 'array'
type = type_for(parent_type_name, nil, property['items'])
inner_type = array_type_for(type)
"list[#{inner_type}]"
elsif property['enum']
enum_name(parent_type_name, property_name, property['enum'])
else
language_translations_for_data_types.fetch(type)
end
end

def enum_constant(value)
value.gsub(/[.\/+]/, '_').downcase
end

def enum_name(parent_type_name, property_name, enum)
"#{class_name(parent_type_name)}#{capitalize(property_name)}".tap do |name|
@enum_set.add({ name: name, values: enum })
end
end

def property_type_from_ref(ref)
class_name(ref)
end

def class_name(ref)
return ref if language_translations_for_data_types.values.include?(ref)

# Remove .json extension if present
name = ref.sub(/\.json$/, '')
# Get the basename without path
name = File.basename(name)
# Convert each word to proper case, handling camelCase and snake_case
parts = name.gsub(/[._-]/, '_').split('_').map do |part|
# Split by any existing camelCase
subparts = part.scan(/[A-Z][a-z]*|[a-z]+/)
subparts.map(&:capitalize).join
end
# Join all parts to create final CamelCase name
parts.join
end
end
end
17 changes: 17 additions & 0 deletions codegen/templates/python.enum.py.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# This code was generated using the code generator from cucumber-messages.
# Manual changes will be lost if the code is regenerated.
# Generator: cucumber-messages-python

from enum import Enum


<%- @enums.each_with_index do |enum, index| -%>
class <%= enum[:name] %>(Enum):
<%- enum[:values].each do |value| -%>
<%= format_enum_value(value) %> = "<%= value %>"
<%- end -%>
<%- if index < @enums.length - 1 -%>


<%- end -%>
<%- end -%>
34 changes: 34 additions & 0 deletions codegen/templates/python.py.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# This code was generated using the code generator from cucumber-messages.
# Manual changes will be lost if the code is regenerated.
# Generator: cucumber-messages-python

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional

from ._message_enums import *

<%- @schemas.each_with_index do |schema_pair, index| -%>
<%- key, definition = schema_pair -%>
@dataclass
class <%= class_name(key) %>:
<%- if definition['description'] -%>
<%= format_description(definition['description']) %>
<%- end -%>
<%- if definition['properties'].any? -%>
<%- required_fields = definition['required'] || [] -%>
<%- get_sorted_properties(definition).each do |property_name, property| -%>
<%= format_property(key, property_name, property, required_fields) %>
<%- if property['description'] && property['description'].include?("\n") -%>
<%= format_description(property['description']) %>

<%- end -%>
<%- end -%>
<%- else -%>
pass
<%- end -%>
<%- if index < @schemas.length - 1 -%>


<%- end -%>
<%- end -%>
Empty file modified cpp/cmake/cmate
100755 → 100644
Empty file.
55 changes: 55 additions & 0 deletions python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
*.rej
*.py[cod]
/.env
*.orig
**/__pycache__

# C extensions
*.so

# Packages
*.egg
*.egg-info
dist
build
_build
eggs
parts
bin
var
sdist
develop-eggs
.installed.cfg
lib
lib64

# Installer logs
pip-log.txt

# Unit test / coverage reports
.coverage
.tox
nosetests.xml

# Translations
*.mo

# Mr Developer
.mr.developer.cfg
.project
.pydevproject
.pytest_cache
.ropeproject

# Sublime
/*.sublime-*

#PyCharm
/.idea

# virtualenv
/.Python
/lib
/include
/share
/local
Loading

0 comments on commit 4ed7f02

Please sign in to comment.