diff --git a/cmd/generate.go b/cmd/generate.go index f62c31f..8a8b600 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -8,6 +8,7 @@ import ( "github.com/open-feature/cli/internal/generators" "github.com/open-feature/cli/internal/generators/golang" "github.com/open-feature/cli/internal/generators/nodejs" + "github.com/open-feature/cli/internal/generators/python" "github.com/open-feature/cli/internal/generators/react" "github.com/open-feature/cli/internal/logger" "github.com/spf13/cobra" @@ -175,11 +176,53 @@ func GetGenerateGoCmd() *cobra.Command { return goCmd } +func getGeneratePythonCmd() *cobra.Command { + pythonCmd := &cobra.Command{ + Use: "python", + Short: "Generate typesafe Python client.", + Long: `Generate typesafe Python client compatible with the OpenFeature Python SDK.`, + Annotations: map[string]string{ + "stability": string(generators.Alpha), + }, + RunE: func(cmd *cobra.Command, args []string) error { + manifestPath := config.GetManifestPath(cmd) + outputPath := config.GetOutputPath(cmd) + + logger.Default.GenerationStarted("Python") + + params := generators.Params[python.Params]{ + OutputPath: outputPath, + Custom: python.Params{}, + } + flagset, err := flagset.Load(manifestPath) + if err != nil { + return err + } + + generator := python.NewGenerator(flagset) + logger.Default.Debug("Executing Python generator") + err = generator.Generate(¶ms) + if err != nil { + return err + } + + logger.Default.GenerationComplete("Python") + + return nil + }, + } + + addStabilityInfo(pythonCmd) + + return pythonCmd +} + func init() { // Register generators with the manager generators.DefaultManager.Register(GetGenerateReactCmd) generators.DefaultManager.Register(GetGenerateGoCmd) generators.DefaultManager.Register(GetGenerateNodeJSCmd) + generators.DefaultManager.Register(getGeneratePythonCmd) } func GetGenerateCmd() *cobra.Command { diff --git a/cmd/generate_test.go b/cmd/generate_test.go index 086d18b..b7db5d1 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -48,6 +48,13 @@ func TestGenerate(t *testing.T) { outputGolden: "testdata/success_nodejs.golden", outputFile: "openfeature.ts", }, + { + name: "Python generation success", + command: "python", + manifestGolden: "testdata/success_manifest.golden", + outputGolden: "testdata/success_python.golden", + outputFile: "openfeature.py", + }, // Add more test cases here as needed } diff --git a/cmd/testdata/success_python.golden b/cmd/testdata/success_python.golden new file mode 100644 index 0000000..93c8d68 --- /dev/null +++ b/cmd/testdata/success_python.golden @@ -0,0 +1,384 @@ +# AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +from typing import Optional + +from openfeature.client import OpenFeatureClient +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagEvaluationOptions +from openfeature.hook import Hook + + +class GeneratedClient: + def __init__( + self, + client: OpenFeatureClient, + ) -> None: + self.client = client + + def discount_percentage( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> float: + """ + Discount percentage applied to purchases. + + **Details:** + - flag key: `discountPercentage` + - default value: `0.15` + - type: `float` + + Performs a flag evaluation that returns a `float`. + """ + return self.client.get_float_value( + flag_key="discountPercentage", + default_value=0.15, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def discount_percentage_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Discount percentage applied to purchases. + + **Details:** + - flag key: `discountPercentage` + - default value: `0.15` + - type: `float` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + return self.client.get_float_details( + flag_key="discountPercentage", + default_value=0.15, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def discount_percentage_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> float: + """ + Discount percentage applied to purchases. + + **Details:** + - flag key: `discountPercentage` + - default value: `0.15` + - type: `float` + + Performs a flag evaluation asynchronously and returns a `float`. + """ + return await self.client.get_float_value_async( + flag_key="discountPercentage", + default_value=0.15, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def discount_percentage_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Discount percentage applied to purchases. + + **Details:** + - flag key: `discountPercentage` + - default value: `0.15` + - type: `float` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + return await self.client.get_float_details_async( + flag_key="discountPercentage", + default_value=0.15, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def enable_feature_a( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> bool: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation that returns a `bool`. + """ + return self.client.get_boolean_value( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def enable_feature_a_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + return self.client.get_boolean_details( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def enable_feature_a_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> bool: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation asynchronously and returns a `bool`. + """ + return await self.client.get_boolean_value_async( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def enable_feature_a_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Controls whether Feature A is enabled. + + **Details:** + - flag key: `enableFeatureA` + - default value: `False` + - type: `bool` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + return await self.client.get_boolean_details_async( + flag_key="enableFeatureA", + default_value=False, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def greeting_message( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> str: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation that returns a `str`. + """ + return self.client.get_string_value( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def greeting_message_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + return self.client.get_string_details( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def greeting_message_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> str: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation asynchronously and returns a `str`. + """ + return await self.client.get_string_value_async( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def greeting_message_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + The message to use for greeting users. + + **Details:** + - flag key: `greetingMessage` + - default value: `Hello there!` + - type: `str` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + return await self.client.get_string_details_async( + flag_key="greetingMessage", + default_value="Hello there!", + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def username_max_length( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> int: + """ + Maximum allowed length for usernames. + + **Details:** + - flag key: `usernameMaxLength` + - default value: `50` + - type: `int` + + Performs a flag evaluation that returns a `int`. + """ + return self.client.get_integer_value( + flag_key="usernameMaxLength", + default_value=50, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def username_max_length_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Maximum allowed length for usernames. + + **Details:** + - flag key: `usernameMaxLength` + - default value: `50` + - type: `int` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + return self.client.get_integer_details( + flag_key="usernameMaxLength", + default_value=50, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def username_max_length_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> int: + """ + Maximum allowed length for usernames. + + **Details:** + - flag key: `usernameMaxLength` + - default value: `50` + - type: `int` + + Performs a flag evaluation asynchronously and returns a `int`. + """ + return await self.client.get_integer_value_async( + flag_key="usernameMaxLength", + default_value=50, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def username_max_length_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + Maximum allowed length for usernames. + + **Details:** + - flag key: `usernameMaxLength` + - default value: `50` + - type: `int` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + return await self.client.get_integer_details_async( + flag_key="usernameMaxLength", + default_value=50, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + +def get_generated_client( + client: Optional[OpenFeatureClient] = None, + domain: Optional[str] = None, + version: Optional[str] = None, + context: Optional[EvaluationContext] = None, + hooks: Optional[list[Hook]] = None, +) -> GeneratedClient: + if not client: + client = OpenFeatureClient( + domain=domain, + version=version, + context=context, + hooks=hooks, + ) + return GeneratedClient(client) diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md index 99fb3ad..4a80d20 100644 --- a/docs/commands/openfeature_generate.md +++ b/docs/commands/openfeature_generate.md @@ -28,5 +28,6 @@ openfeature generate [flags] * [openfeature](openfeature.md) - CLI for OpenFeature. * [openfeature generate go](openfeature_generate_go.md) - Generate typesafe accessors for OpenFeature. * [openfeature generate nodejs](openfeature_generate_nodejs.md) - Generate typesafe Node.js client. +* [openfeature generate python](openfeature_generate_python.md) - Generate typesafe Python client. * [openfeature generate react](openfeature_generate_react.md) - Generate typesafe React Hooks. diff --git a/docs/commands/openfeature_generate_python.md b/docs/commands/openfeature_generate_python.md new file mode 100644 index 0000000..e48b32f --- /dev/null +++ b/docs/commands/openfeature_generate_python.md @@ -0,0 +1,36 @@ + + +## openfeature generate python + +Generate typesafe Python client. + + +> **Stability**: alpha + +### Synopsis + +Generate typesafe Python client compatible with the OpenFeature Python SDK. + +``` +openfeature generate python [flags] +``` + +### Options + +``` + -h, --help help for python +``` + +### Options inherited from parent commands + +``` + --debug Enable debug logging + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts + -o, --output string Path to where the generated files should be saved +``` + +### SEE ALSO + +* [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. + diff --git a/internal/generators/python/python.go b/internal/generators/python/python.go new file mode 100644 index 0000000..4ded095 --- /dev/null +++ b/internal/generators/python/python.go @@ -0,0 +1,102 @@ +package python + +import ( + _ "embed" + "text/template" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +type PythonGenerator struct { + generators.CommonGenerator +} + +type Params struct { +} + +//go:embed python.tmpl +var pythonTmpl string + +func openFeatureType(t flagset.FlagType) string { + switch t { + case flagset.IntType: + return "int" + case flagset.FloatType: + return "float" + case flagset.BoolType: + return "bool" + case flagset.StringType: + return "str" + default: + return "object" + } +} + +func methodType(flagType flagset.FlagType) string { + switch flagType { + case flagset.StringType: + return "string" + case flagset.IntType: + return "integer" + case flagset.BoolType: + return "boolean" + case flagset.FloatType: + return "float" + default: + panic("unsupported flag type") + } +} + +func typedGetMethodSync(flagType flagset.FlagType) string { + return "get_" + methodType(flagType) + "_value" +} + +func typedGetMethodAsync(flagType flagset.FlagType) string { + return "get_" + methodType(flagType) + "_value_async" +} + +func typedDetailsMethodSync(flagType flagset.FlagType) string { + return "get_" + methodType(flagType) + "_details" +} + +func typedDetailsMethodAsync(flagType flagset.FlagType) string { + return "get_" + methodType(flagType) + "_details_async" +} + +func pythonBoolLiteral(value interface{}) interface{} { + if v, ok := value.(bool); ok { + if v { + return "True" + } + return "False" + } + return value +} + +func (g *PythonGenerator) Generate(params *generators.Params[Params]) error { + funcs := template.FuncMap{ + "OpenFeatureType": openFeatureType, + "TypedGetMethodSync": typedGetMethodSync, + "TypedGetMethodAsync": typedGetMethodAsync, + "TypedDetailsMethodSync": typedDetailsMethodSync, + "TypedDetailsMethodAsync": typedDetailsMethodAsync, + "PythonBoolLiteral": pythonBoolLiteral, + } + + newParams := &generators.Params[any]{ + OutputPath: params.OutputPath, + Custom: Params{}, + } + + return g.GenerateFile(funcs, pythonTmpl, newParams, "openfeature.py") +} + +// NewGenerator creates a generator for Python. +func NewGenerator(fs *flagset.Flagset) *PythonGenerator { + return &PythonGenerator{ + CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ + flagset.ObjectType: true, + }), + } +} diff --git a/internal/generators/python/python.tmpl b/internal/generators/python/python.tmpl new file mode 100644 index 0000000..e6fbdd8 --- /dev/null +++ b/internal/generators/python/python.tmpl @@ -0,0 +1,121 @@ +# AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +from typing import Optional + +from openfeature.client import OpenFeatureClient +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagEvaluationOptions +from openfeature.hook import Hook + + +class GeneratedClient: + def __init__( + self, + client: OpenFeatureClient, + ) -> None: + self.client = client +{{ printf "" }} +{{- range .Flagset.Flags }} + def {{ .Key | ToSnake }}( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> {{ .Type | OpenFeatureType }}: + """ + {{ .Description }} + + **Details:** + - flag key: `{{ .Key }}` + - default value: `{{ .DefaultValue | PythonBoolLiteral }}` + - type: `{{ .Type | OpenFeatureType }}` + + Performs a flag evaluation that returns a `{{ .Type | OpenFeatureType }}`. + """ + return self.client.{{ .Type | TypedGetMethodSync }}( + flag_key={{ .Key | Quote }}, + default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + def {{ .Key | ToSnake }}_details( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + {{ .Description }} + + **Details:** + - flag key: `{{ .Key }}` + - default value: `{{ .DefaultValue | PythonBoolLiteral }}` + - type: `{{ .Type | OpenFeatureType }}` + + Performs a flag evaluation that returns a `FlagEvaluationDetails` instance. + """ + return self.client.{{ .Type | TypedDetailsMethodSync }}( + flag_key={{ .Key | Quote }}, + default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def {{ .Key | ToSnake }}_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> {{ .Type | OpenFeatureType }}: + """ + {{ .Description }} + + **Details:** + - flag key: `{{ .Key }}` + - default value: `{{ .DefaultValue | PythonBoolLiteral }}` + - type: `{{ .Type | OpenFeatureType }}` + + Performs a flag evaluation asynchronously and returns a `{{ .Type | OpenFeatureType }}`. + """ + return await self.client.{{ .Type | TypedGetMethodAsync }}( + flag_key={{ .Key | Quote }}, + default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) + + async def {{ .Key | ToSnake }}_details_async( + self, + evaluation_context: Optional[EvaluationContext] = None, + flag_evaluation_options: Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails: + """ + {{ .Description }} + + **Details:** + - flag key: `{{ .Key }}` + - default value: `{{ .DefaultValue | PythonBoolLiteral }}` + - type: `{{ .Type | OpenFeatureType }}` + + Performs a flag evaluation asynchronously and returns a `FlagEvaluationDetails` instance. + """ + return await self.client.{{ .Type | TypedDetailsMethodAsync }}( + flag_key={{ .Key | Quote }}, + default_value={{ .DefaultValue | QuoteString | PythonBoolLiteral }}, + evaluation_context=evaluation_context, + flag_evaluation_options=flag_evaluation_options, + ) +{{ end -}} +{{ printf "\n" }} +def get_generated_client( + client: Optional[OpenFeatureClient] = None, + domain: Optional[str] = None, + version: Optional[str] = None, + context: Optional[EvaluationContext] = None, + hooks: Optional[list[Hook]] = None, +) -> GeneratedClient: + if not client: + client = OpenFeatureClient( + domain=domain, + version=version, + context=context, + hooks=hooks, + ) + return GeneratedClient(client)