Skip to content

Commit e932689

Browse files
authored
Merge pull request #14 from svix/mendy/add-python-template
python: Add codegen for python lib_resource
2 parents 364dfa3 + 72e0612 commit e932689

File tree

6 files changed

+194
-34
lines changed

6 files changed

+194
-34
lines changed

src/generator.rs

+6-6
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::{
1111
api::Api,
1212
template,
1313
types::Types,
14-
util::{parse_frontmatter, run_formatter},
14+
util::{parse_frontmatter, run_postprocessing},
1515
};
1616

1717
#[derive(Default, Deserialize)]
@@ -34,7 +34,7 @@ pub(crate) fn generate(
3434
types: Types,
3535
tpl_name: String,
3636
output_dir: &Utf8Path,
37-
no_format: bool,
37+
no_postprocess: bool,
3838
) -> anyhow::Result<()> {
3939
let (tpl_file_ext, tpl_filename) = match tpl_name.strip_suffix(".jinja") {
4040
Some(basename) => (extension(basename), &tpl_name),
@@ -56,7 +56,7 @@ pub(crate) fn generate(
5656
tpl,
5757
tpl_file_ext,
5858
output_dir,
59-
no_format,
59+
no_postprocess,
6060
};
6161

6262
match tpl_frontmatter.template_kind {
@@ -70,7 +70,7 @@ struct Generator<'a> {
7070
tpl: Template<'a, 'a>,
7171
tpl_file_ext: &'a str,
7272
output_dir: &'a Utf8Path,
73-
no_format: bool,
73+
no_postprocess: bool,
7474
}
7575

7676
impl Generator<'_> {
@@ -112,8 +112,8 @@ impl Generator<'_> {
112112
let out_file = BufWriter::new(File::create(&file_path)?);
113113

114114
self.tpl.render_to_write(ctx, out_file)?;
115-
if !self.no_format {
116-
run_formatter(&file_path);
115+
if !self.no_postprocess {
116+
run_postprocessing(&file_path);
117117
}
118118

119119
Ok(())

src/main.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ enum Command {
3232
#[clap(short, long)]
3333
input_file: String,
3434

35-
/// Disable automatic formatting of the output.
35+
/// Disable automatic postprocessing of the output (formatting and automatic style fixes).
3636
#[clap(long)]
37-
no_format: bool,
37+
no_postprocess: bool,
3838

3939
/// Generate code for deprecated operations, too.
4040
#[clap(long)]
@@ -49,7 +49,7 @@ fn main() -> anyhow::Result<()> {
4949
let Command::Generate {
5050
template,
5151
input_file,
52-
no_format,
52+
no_postprocess,
5353
with_deprecated,
5454
} = args.command;
5555

@@ -81,7 +81,7 @@ fn main() -> anyhow::Result<()> {
8181
.path()
8282
.try_into()
8383
.context("non-UTF8 tempdir path")?,
84-
no_format,
84+
no_postprocess,
8585
)?;
8686
}
8787

src/template.rs

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ pub(crate) fn env() -> Result<minijinja::Environment<'static>, minijinja::Error>
2222
kwargs.assert_all_used()?;
2323

2424
let prefix = match &*style {
25+
"python" => {
26+
return Ok(format!(r#""""{s}""""#));
27+
}
2528
"java" | "kotlin" | "javascript" | "js" | "ts" | "typescript" => {
2629
if !s.contains("\n") {
2730
return Ok(format!("/** {s} */"));

src/types.rs

+21
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,23 @@ impl FieldType {
418418
_ => None,
419419
}
420420
}
421+
422+
fn to_python_typename(&self) -> Cow<'_, str> {
423+
match self {
424+
Self::Bool => "bool".into(),
425+
Self::Int16 | Self::UInt16 | Self::Int32 | Self::Int64 | Self::UInt64 => "int".into(),
426+
Self::String => "str".into(),
427+
Self::DateTime => "datetime".into(),
428+
Self::Set(field_type) => format!("t.Set[{}]", field_type.to_python_typename()).into(),
429+
Self::SchemaRef(name) => format!("models.{name}").into(),
430+
Self::Uri => "str".into(),
431+
Self::JsonObject => "t.Dict[str, t.Any]".into(),
432+
Self::List(field_type) => format!("t.List[{}]", field_type.to_python_typename()).into(),
433+
Self::Map { value_ty } => {
434+
format!("t.Dict[str, {}]", value_ty.to_python_typename()).into()
435+
}
436+
}
437+
}
421438
}
422439

423440
impl minijinja::value::Object for FieldType {
@@ -432,6 +449,10 @@ impl minijinja::value::Object for FieldType {
432449
args: &[minijinja::Value],
433450
) -> Result<minijinja::Value, minijinja::Error> {
434451
match method {
452+
"to_python" => {
453+
ensure_no_args(args, "to_python")?;
454+
Ok(self.to_python_typename().into())
455+
}
435456
"to_csharp" => {
436457
ensure_no_args(args, "to_csharp")?;
437458
Ok(self.to_csharp_typename().into())

src/util.rs

+36-24
Original file line numberDiff line numberDiff line change
@@ -23,48 +23,60 @@ pub(crate) fn get_schema_name(maybe_ref: Option<&str>) -> Option<String> {
2323
Some(schema_name?.to_owned())
2424
}
2525

26-
pub(crate) fn run_formatter(path: &Utf8Path) {
26+
pub(crate) fn run_postprocessing(path: &Utf8Path) {
2727
let Some(file_ext) = path.extension() else {
2828
return;
2929
};
3030

31-
let (formatter, args) = match file_ext {
32-
"rs" => (
33-
"rustfmt",
34-
[
35-
"+nightly",
36-
"--unstable-features",
37-
"--skip-children",
38-
"--edition",
39-
"2021",
40-
]
41-
.as_slice(),
42-
),
43-
"go" => ("gofmt", ["-w"].as_slice()),
44-
"kt" => ("ktfmt", ["--kotlinlang-style"].as_slice()),
45-
_ => {
46-
tracing::debug!("no known formatter for {file_ext} files");
47-
return;
31+
let postprocessing_tasks: &[(&str, &[&str])] = {
32+
match file_ext {
33+
"py" => &[
34+
// fixme: the ordering of the commands is important, maybe ensure the order in a more robust way
35+
("ruff", ["check", "--fix"].as_slice()),
36+
("ruff", ["format"].as_slice()),
37+
],
38+
"rs" => &[(
39+
"rustfmt",
40+
[
41+
"+nightly",
42+
"--unstable-features",
43+
"--skip-children",
44+
"--edition",
45+
"2021",
46+
]
47+
.as_slice(),
48+
)],
49+
"go" => &[("gofmt", ["-w"].as_slice())],
50+
"kt" => &[("ktfmt", ["--kotlinlang-style"].as_slice())],
51+
_ => {
52+
tracing::debug!("no known postprocessing command(s) for {file_ext} files");
53+
return;
54+
}
4855
}
4956
};
57+
for (command, args) in postprocessing_tasks {
58+
execute_postprocessing_command(path, command, args);
59+
}
60+
}
5061

51-
let result = Command::new(formatter).args(args).arg(path).status();
62+
fn execute_postprocessing_command(path: &Utf8Path, command: &'static str, args: &[&str]) {
63+
let result = Command::new(command).args(args).arg(path).status();
5264
match result {
5365
Ok(exit_status) if exit_status.success() => {}
5466
Ok(exit_status) => {
55-
tracing::warn!(exit_status = exit_status.code(), "`{formatter}` failed");
67+
tracing::warn!(exit_status = exit_status.code(), "`{command}` failed");
5668
}
5769
Err(e) if e.kind() == io::ErrorKind::NotFound => {
58-
// only print one error per formatter that's not found
70+
// only print one error per command that's not found
5971
static NOT_FOUND_LOGGED_FOR: Mutex<BTreeSet<&str>> = Mutex::new(BTreeSet::new());
60-
if NOT_FOUND_LOGGED_FOR.lock().unwrap().insert(formatter) {
61-
tracing::warn!("`{formatter}` not found");
72+
if NOT_FOUND_LOGGED_FOR.lock().unwrap().insert(command) {
73+
tracing::warn!("`{command}` not found");
6274
}
6375
}
6476
Err(e) => {
6577
tracing::warn!(
6678
error = &e as &dyn std::error::Error,
67-
"running `{formatter}` failed"
79+
"running `{command}` failed"
6880
);
6981
}
7082
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
{% set api_mod_name %}{{ resource.name | to_snake_case }}_api{% endset -%}
2+
{% set resource_class_name = resource.name | to_upper_camel_case -%}
3+
{% set resource_type_name = resource.name | to_upper_camel_case -%}
4+
5+
import typing as t
6+
from datetime import datetime
7+
from dataclasses import dataclass,asdict
8+
9+
from deprecated import deprecated
10+
from .common import PostOptions, ApiBase, BaseOptions
11+
from ..internal.openapi_client.client import AuthenticatedClient
12+
from ..internal.openapi_client import models
13+
14+
{% if resource.operations | length > 0 %}
15+
16+
from ..internal.openapi_client.api.{{ resource.name | to_snake_case }} import (
17+
{% for op in resource.operations %}
18+
{{ op.id | to_snake_case }},
19+
{%- endfor %}
20+
)
21+
{% for op in resource.operations -%}
22+
{#-
23+
FIXME: this is a hack. i want to be able to export the resource types from this file so i have to import them (not just use models.Resource)
24+
But this will result in duplicate import statements. ruff check --fix will remove them, but ideally we find a better way to do this
25+
-#}
26+
{%- if op.request_body_schema_name is defined %}
27+
from ..internal.openapi_client.models.{{ op.request_body_schema_name | to_snake_case }} import {{ op.request_body_schema_name | to_upper_camel_case }}
28+
{%- endif -%}
29+
{%- if op.response_body_schema_name is defined %}
30+
from ..internal.openapi_client.models.{{ op.response_body_schema_name | to_snake_case }} import {{ op.response_body_schema_name | to_upper_camel_case }}
31+
{%- endif -%}
32+
{% endfor %}
33+
{% endif %}
34+
35+
36+
{# FIXME: need to understand how this template generates newlines and stop generating newlines #}
37+
{% for op in resource.operations %}
38+
{% if op.query_params | length > 0 or op.header_params | length > 0 %}
39+
@dataclass
40+
class {{ resource_type_name }}{{ op.name | to_upper_camel_case }}Options(BaseOptions):
41+
{%- for p in op.query_params %}
42+
{%- if p.description is defined %}
43+
# {{ p.description }}
44+
{%- endif %}
45+
{%- if p.required %}
46+
{{ p.name }}: {{ p.type.to_python() }}
47+
{%- else %}
48+
{{ p.name }}: t.Optional[{{ p.type.to_python() }}] = None
49+
{%- endif %}
50+
{%- endfor %}
51+
{# FIXME: hardcoding idempotency-key for now since that is the only header param #}
52+
{%- if op.header_params | length > 0 %}
53+
idempotency_key: t.Optional[str] = None
54+
{%- endif %}
55+
{%- endif %}
56+
{%- endfor %}
57+
58+
59+
{%- for is_async in [true, false] %}
60+
class {{ resource.name | to_upper_camel_case }}{% if is_async %}Async{% endif %}(ApiBase):
61+
{%- if resource.operations | length != 0 %}
62+
{%- for op in resource.operations %}
63+
{# FIXME: find a better way to deal with python's reserved keywords!, for now i do this hack #}
64+
{%- if op.name == "import" %}
65+
{%- set op_name = "import_" %}
66+
{%- else %}
67+
{%- set op_name = op.name | to_snake_case %}
68+
{%- endif %}
69+
{% if op.deprecated %}
70+
@deprecated
71+
{%- endif %}
72+
{%- if is_async -%}
73+
{%- set func_def = "async def" %}
74+
{%- else %}
75+
{%- set func_def = "def" %}
76+
{%- endif %}
77+
{{ func_def }} {{ op_name }}(self
78+
{#- path parameters are non optional strings #}
79+
{% for p in op.path_params -%}
80+
,{{ p }}: str
81+
{% endfor -%}
82+
{# body parameter struct #}
83+
{%- if op.request_body_schema_name is defined %}
84+
,{{ op.request_body_schema_name | to_snake_case }}: {{ op.request_body_schema_name }}
85+
{%- endif %}
86+
87+
{# add query_options type #}
88+
{%- if op.query_params | length > 0 or op.header_params | length > 0 %}
89+
,options: {{ resource_type_name }}{{ op.name | to_upper_camel_case }}Options = {{ resource_type_name }}{{ op.name | to_upper_camel_case }}Options()
90+
{%- endif %}
91+
92+
) ->
93+
{%- if op.response_body_schema_name is defined -%}
94+
{{ op.response_body_schema_name | to_upper_camel_case }}:
95+
{% else -%}
96+
None:
97+
{%- endif %}
98+
{% if op.description is defined -%}
99+
{{ op.description | to_doc_comment(style="python") }}
100+
{%- endif -%}
101+
{%- set internal_func_name -%}
102+
{{ op.id | to_snake_case }}{% if is_async -%}.request_asyncio{% else -%}.request_sync{% endif -%}
103+
{%- endset -%}
104+
{% set ret -%}
105+
{%- if is_async -%}return await{% else %}return{% endif -%}
106+
{%- endset %}
107+
{{ ret }} {{ internal_func_name }}(client=self._client
108+
{%- for p in op.path_params -%}
109+
,{{ p }}={{ p }}
110+
{%- endfor -%}
111+
{% if op.request_body_schema_name is defined -%}
112+
,json_body={{ op.request_body_schema_name | to_snake_case }}
113+
{% endif -%}
114+
{% if (op.query_params | length > 0 )or (op.header_params | length > 0) %}
115+
{# FIXME: how do we know there is no duplicate options in the path params #}
116+
,**options.to_dict()
117+
{% endif -%}
118+
)
119+
{% endfor -%}
120+
{% else %}
121+
{# empty class with no functions, so we have a pass here #}
122+
pass
123+
{% endif %}
124+
{% endfor %}

0 commit comments

Comments
 (0)