Skip to content

Commit 4ed7f02

Browse files
elchupanebrejluke-hilldavidjgoss
authored
Add messages implementation for python (#165)
* [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 <[email protected]> * Update CHANGELOG.md --------- Co-authored-by: Luke Hill <[email protected]> Co-authored-by: David Goss <[email protected]>
1 parent f375dce commit 4ed7f02

23 files changed

+2033
-1
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Release Python
2+
3+
on:
4+
push:
5+
branches: [release/*]
6+
7+
jobs:
8+
release:
9+
name: Release
10+
runs-on: ubuntu-latest
11+
environment: Release
12+
permissions:
13+
id-token: write
14+
defaults:
15+
run:
16+
working-directory: python
17+
steps:
18+
- name: Checkout code
19+
uses: actions/checkout@v4
20+
21+
- uses: cucumber/[email protected]
22+
with:
23+
working-directory: "python"

.github/workflows/test-python.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
name: test-python
3+
4+
on:
5+
push:
6+
branches:
7+
- main
8+
- renovate/**
9+
pull_request:
10+
branches:
11+
- main
12+
workflow_dispatch:
13+
14+
jobs:
15+
build:
16+
17+
runs-on: ${{ matrix.os }}
18+
strategy:
19+
matrix:
20+
include:
21+
# Test latest python on Windows / macOS
22+
- { os: 'windows-latest', python-version: '3.13' }
23+
- { os: 'macos-latest', python-version: '3.13' }
24+
os: ['ubuntu-latest']
25+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.10']
26+
27+
steps:
28+
- uses: actions/checkout@v4
29+
- name: Set up Python ${{ matrix.python-version }}
30+
uses: actions/setup-python@v5
31+
with:
32+
python-version: ${{ matrix.python-version }}
33+
- name: Install dependencies
34+
run: |
35+
python -m pip install --upgrade pip
36+
pip install -U setuptools
37+
pip install tox tox-gh-actions codecov
38+
- name: Test with tox
39+
working-directory: ./python
40+
run: |
41+
tox
42+
- name: Gather codecov report
43+
working-directory: ./python
44+
run: |
45+
codecov

.pre-commit-config.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# See https://pre-commit.com for more information
2+
# See https://pre-commit.com/hooks.html for more hooks
3+
---
4+
files: ^python/
5+
exclude: .*python/src/cucumber_messages/_messages\.py
6+
repos:
7+
- repo: https://github.com/psf/black
8+
rev: 24.10.0
9+
hooks:
10+
- id: black
11+
args:
12+
- "python/src"
13+
- "python/tests"
14+
- repo: https://github.com/pycqa/isort
15+
rev: 5.13.2
16+
hooks:
17+
- id: isort
18+
- repo: https://github.com/pre-commit/pre-commit-hooks
19+
rev: v5.0.0
20+
hooks:
21+
- id: trailing-whitespace
22+
- id: end-of-file-fixer
23+
- id: check-added-large-files
24+
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
25+
rev: v2.14.0
26+
hooks:
27+
- id: pretty-format-toml
28+
args: [--autofix]
29+
- repo: https://github.com/asottile/pyupgrade
30+
rev: v3.19.1
31+
hooks:
32+
- id: pyupgrade
33+
args: ["--py39-plus"]
34+
- repo: https://github.com/pre-commit/mirrors-mypy
35+
rev: v1.13.0
36+
hooks:
37+
- id: mypy
38+
additional_dependencies: [types-setuptools, types-certifi]

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

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

1012
## [27.1.0] - 2025-01-28
1113
### Added

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ schemas = \
2727
./jsonschema/UndefinedParameterType.json \
2828
./jsonschema/Envelope.json
2929

30-
languages = cpp go java javascript perl php ruby dotnet
30+
languages = cpp dotnet go java javascript perl php python ruby
3131

3232
.DEFAULT_GOAL = help
3333

codegen/codegen.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
require_relative 'generators/markdown'
1212
require_relative 'generators/perl'
1313
require_relative 'generators/php'
14+
require_relative 'generators/python'
1415
require_relative 'generators/ruby'
1516
require_relative 'generators/typescript'
1617

codegen/generators/python.rb

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# frozen_string_literal: true
2+
3+
module Generator
4+
class Python < Base
5+
def format_enum_value(value)
6+
value.downcase.gsub(/[.\/+\s-]/, '_')
7+
end
8+
9+
def get_sorted_properties(definition)
10+
required_fields = definition['required'] || []
11+
definition['properties'].sort_by do |name, *|
12+
[required_fields.include?(name) ? 0 : 1, name]
13+
end
14+
end
15+
16+
def format_property(parent_type_name, property_name, property, required_fields)
17+
snake_name = property_name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
18+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
19+
.downcase
20+
21+
property_type = get_property_type(parent_type_name, property_name, property)
22+
is_required = required_fields.include?(property_name)
23+
24+
property_description = if property['description'] && !property['description'].include?("\n")
25+
" # #{property['description']}"
26+
else
27+
''
28+
end
29+
if is_required
30+
"#{snake_name}: #{property_type}#{property_description}"
31+
else
32+
"#{snake_name}: Optional[#{property_type}] = None#{property_description}"
33+
end
34+
end
35+
36+
def get_property_type(parent_type_name, property_name, property)
37+
type = type_for(parent_type_name, property_name, property)
38+
type.match?(/\A[A-Z]/) ? class_name(type) : type
39+
end
40+
41+
def array_type_for(type_name)
42+
inner_type = if language_translations_for_data_types.values.include?(type_name)
43+
type_name # Keep primitive types as is
44+
else
45+
class_name(type_name) # CamelCase for complex types
46+
end
47+
inner_type
48+
end
49+
50+
def format_description(raw_description, indent_string: ' ')
51+
return '""" """' if raw_description.nil?
52+
53+
lines = raw_description.split("\n").map { |line|
54+
if line.strip.empty?
55+
""
56+
else
57+
"#{indent_string}#{line.rstrip}"
58+
end
59+
}
60+
61+
%("""\n#{lines.join("\n")}\n#{indent_string}""")
62+
end
63+
64+
def language_translations_for_data_types
65+
{
66+
'integer' => 'int',
67+
'string' => 'str',
68+
'boolean' => 'bool',
69+
'array' => 'list'
70+
}
71+
end
72+
73+
private
74+
75+
def default_value(parent_type_name, property_name, property)
76+
if property['type'] == 'string'
77+
default_value_for_string(parent_type_name, property_name, property)
78+
elsif property['type'] == 'integer'
79+
'0'
80+
elsif property['type'] == 'boolean'
81+
'False'
82+
elsif property['type'] == 'array'
83+
'[]'
84+
elsif property['$ref']
85+
"#{class_name(type_for(parent_type_name, nil, property))}()"
86+
else
87+
'None'
88+
end
89+
end
90+
91+
def default_value_for_string(parent_type_name, property_name, property)
92+
if property['enum']
93+
enum_type_name = type_for(parent_type_name, property_name, property)
94+
"#{class_name(enum_type_name)}.#{enum_constant(property['enum'][0])}"
95+
else
96+
'""'
97+
end
98+
end
99+
100+
def type_for(parent_type_name, property_name, property)
101+
if property['$ref']
102+
property_type_from_ref(property['$ref'])
103+
elsif property['type']
104+
property_type_from_type(parent_type_name, property_name, property, type: property['type'])
105+
else
106+
raise "Property #{property_name} did not define 'type' or '$ref'"
107+
end
108+
end
109+
110+
def property_type_from_type(parent_type_name, property_name, property, type:)
111+
if type == 'array'
112+
type = type_for(parent_type_name, nil, property['items'])
113+
inner_type = array_type_for(type)
114+
"list[#{inner_type}]"
115+
elsif property['enum']
116+
enum_name(parent_type_name, property_name, property['enum'])
117+
else
118+
language_translations_for_data_types.fetch(type)
119+
end
120+
end
121+
122+
def enum_constant(value)
123+
value.gsub(/[.\/+]/, '_').downcase
124+
end
125+
126+
def enum_name(parent_type_name, property_name, enum)
127+
"#{class_name(parent_type_name)}#{capitalize(property_name)}".tap do |name|
128+
@enum_set.add({ name: name, values: enum })
129+
end
130+
end
131+
132+
def property_type_from_ref(ref)
133+
class_name(ref)
134+
end
135+
136+
def class_name(ref)
137+
return ref if language_translations_for_data_types.values.include?(ref)
138+
139+
# Remove .json extension if present
140+
name = ref.sub(/\.json$/, '')
141+
# Get the basename without path
142+
name = File.basename(name)
143+
# Convert each word to proper case, handling camelCase and snake_case
144+
parts = name.gsub(/[._-]/, '_').split('_').map do |part|
145+
# Split by any existing camelCase
146+
subparts = part.scan(/[A-Z][a-z]*|[a-z]+/)
147+
subparts.map(&:capitalize).join
148+
end
149+
# Join all parts to create final CamelCase name
150+
parts.join
151+
end
152+
end
153+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# This code was generated using the code generator from cucumber-messages.
2+
# Manual changes will be lost if the code is regenerated.
3+
# Generator: cucumber-messages-python
4+
5+
from enum import Enum
6+
7+
8+
<%- @enums.each_with_index do |enum, index| -%>
9+
class <%= enum[:name] %>(Enum):
10+
<%- enum[:values].each do |value| -%>
11+
<%= format_enum_value(value) %> = "<%= value %>"
12+
<%- end -%>
13+
<%- if index < @enums.length - 1 -%>
14+
15+
16+
<%- end -%>
17+
<%- end -%>

codegen/templates/python.py.erb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# This code was generated using the code generator from cucumber-messages.
2+
# Manual changes will be lost if the code is regenerated.
3+
# Generator: cucumber-messages-python
4+
5+
from __future__ import annotations
6+
from dataclasses import dataclass
7+
from typing import Optional
8+
9+
from ._message_enums import *
10+
11+
<%- @schemas.each_with_index do |schema_pair, index| -%>
12+
<%- key, definition = schema_pair -%>
13+
@dataclass
14+
class <%= class_name(key) %>:
15+
<%- if definition['description'] -%>
16+
<%= format_description(definition['description']) %>
17+
<%- end -%>
18+
<%- if definition['properties'].any? -%>
19+
<%- required_fields = definition['required'] || [] -%>
20+
<%- get_sorted_properties(definition).each do |property_name, property| -%>
21+
<%= format_property(key, property_name, property, required_fields) %>
22+
<%- if property['description'] && property['description'].include?("\n") -%>
23+
<%= format_description(property['description']) %>
24+
25+
<%- end -%>
26+
<%- end -%>
27+
<%- else -%>
28+
pass
29+
<%- end -%>
30+
<%- if index < @schemas.length - 1 -%>
31+
32+
33+
<%- end -%>
34+
<%- end -%>

cpp/cmake/cmate

100755100644
File mode changed.

0 commit comments

Comments
 (0)