Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# unreleased changes
* API spec update 2026-06-05
* technically a breaking change, as dropbox_sdk::types::sharing::RelinquishAccessError::GroupAccess was removed, but this is very minor
* Annotate struct fields and enum variants as deprecated or preview, where appropriate.

# v0.20.0
2026-05-12
Expand Down
70 changes: 50 additions & 20 deletions generator/rust.stoneg.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ def _emit_struct(self, struct: ir.Struct) -> None:
name = self.field_name(field)
typ = self._rust_type(field.data_type)
self._emit_doc(field.doc)
if field.deprecated:
self.emit(f'#[deprecated]')
if field.preview:
self._emit_preview_attr(field)
self.emit(f'pub {name}: {typ},')
self.emit()

Expand Down Expand Up @@ -197,6 +201,10 @@ def _emit_polymorphic_struct(self, struct: ir.Struct) -> None:
for subtype in struct.get_enumerated_subtypes():
name = self.enum_variant_name(subtype)
typ = self._rust_type(subtype.data_type)
if subtype.deprecated:
self.emit(f'#[deprecated]')
if subtype.preview:
self._emit_preview_attr(subtype)
self.emit(f'{name}({typ}),')
if struct.is_catch_all():
self._emit_other_variant()
Expand All @@ -219,6 +227,10 @@ def _emit_union(self, union: ir.Union) -> None:
# Handle the 'Other' variant at the end.
continue
self._emit_doc(field.doc)
if field.deprecated:
self.emit(f'#[deprecated]')
if field.preview:
self._emit_preview_attr(field)
variant_name = self.enum_variant_name(field)
if isinstance(field.data_type, ir.Void):
self.emit(f'{variant_name},')
Expand Down Expand Up @@ -294,12 +306,7 @@ def _emit_route(self, ns: str, fn: ir.ApiRoute, auth_trait: Optional[str] = None
self._emit_doc(fn.doc)

if fn.attrs.get('is_preview'):
if fn.doc:
self.emit('///')
self.emit('/// # Stability')
self.emit('/// *PREVIEW*: This function may change or disappear without notice.')
self.emit('#[cfg(feature = "unstable")]')
self.emit('#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]')
self._emit_preview_attr(fn)

if fn.deprecated:
if fn.deprecated.by:
Expand Down Expand Up @@ -389,6 +396,14 @@ def _emit_other_variant(self) -> None:
prefix='/// ', width=100)
self.emit('Other,')

def _emit_preview_attr(self, thing: ir.Field | ir.ApiRoute) -> None:
if thing.doc:
self.emit('///')
self.emit('/// # Stability')
self.emit('/// *PREVIEW*: This function may change or disappear without notice.')
self.emit('#[cfg(feature = "unstable")]')
self.emit('#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]')

# Serialization

def _impl_serde_for_struct(self, struct: ir.Struct) -> None:
Expand Down Expand Up @@ -468,28 +483,29 @@ def _impl_serde_for_struct(self, struct: ir.Struct) -> None:
with self.block(f'let result = {type_name}', delim=('{', '};')):
for field in struct.all_fields:
field_name = self.field_name(field)
attr = '#[allow(deprecated)] ' if field.deprecated else ''
if isinstance(field.data_type, ir.Nullable):
# None -> field is not present
# Some(None) -> field is present with null value
# Some(Some(x)) -> field is present and non-null
# First two are equivalent here, hence Option::flatten().
self.emit(f'{field_name}: field_{field_name}.and_then(Option::flatten),')
self.emit(f'{attr}{field_name}: field_{field_name}.and_then(Option::flatten),')
elif field.has_default:
default_value = self._default_value(field)
if isinstance(field.data_type, ir.String) \
and not field.default:
self.emit(f'{field_name}: field_{field_name}.unwrap_or_default(),')
self.emit(f'{attr}{field_name}: field_{field_name}.unwrap_or_default(),')
elif (ir.is_primitive_type(ir.unwrap_aliases(field.data_type)[0])
# Also, as a rough but effective heuristic, consider values
# that have no parentheses in them to be "trivial", and
# don't enclose them in a closure. This avoids running
# afoul of the clippy::unnecessary_lazy_evaluations lint.
or not "(" in default_value):
self.emit(f'{field_name}: field_{field_name}.unwrap_or({default_value}),')
self.emit(f'{attr}{field_name}: field_{field_name}.unwrap_or({default_value}),')
else:
self.emit(f'{field_name}: field_{field_name}.unwrap_or_else(|| {default_value}),')
self.emit(f'{attr}{field_name}: field_{field_name}.unwrap_or_else(|| {default_value}),')
else:
self.emit(f'{field_name}: field_{field_name}.ok_or_else(|| '
self.emit(f'{attr}{field_name}: field_{field_name}.ok_or_else(|| '
f'::serde::de::Error::missing_field("{field.name}"))?,')
if optional:
self.emit('Ok(Some(result))')
Expand All @@ -504,6 +520,8 @@ def _impl_serde_for_struct(self, struct: ir.Struct) -> None:
access='pub(crate)'):
self.emit('use serde::ser::SerializeStruct;')
for field in struct.all_fields:
if field.deprecated:
self.emit('#[allow(deprecated)]')
if ir.is_nullable_type(field.data_type):
# note: Stone requires a field can't be nullable and also have a
# non-null default
Expand Down Expand Up @@ -658,6 +676,8 @@ def _impl_serde_for_union(self, union: ir.Union) -> None:
continue
variant_name = self.enum_variant_name(field)
ultimate_type = ir.unwrap(field.data_type)[0]
if field.deprecated:
self.emit('#[allow(deprecated)]')
if isinstance(field.data_type, ir.Void):
self.emit(f'"{field.name}" => {type_name}::{variant_name},')
elif isinstance(ultimate_type, ir.Struct) \
Expand Down Expand Up @@ -718,6 +738,8 @@ def _impl_serde_for_union(self, union: ir.Union) -> None:
if field.catch_all:
# Handle the 'Other' variant at the end.
continue
if field.deprecated:
self.emit('#[allow(deprecated)]')
variant_name = self.enum_variant_name(field)
if isinstance(field.data_type, ir.Void):
with self.block(f'{type_name}::{variant_name} =>'):
Expand Down Expand Up @@ -788,7 +810,8 @@ def _impl_from_for_struct(self, struct: ir.Struct, parent: ir.Struct) -> None:
with self.block('Self'):
for field in parent.all_fields:
field_name = self.field_name(field)
self.emit(f'{field_name}: subtype.{field_name},')
attr = '#[allow(deprecated)] ' if field.deprecated else ''
self.emit(f'{attr}{field_name}: subtype.{field_name},')

# "extends" for polymorphic structs means it's one of the supertype's variants, so we can
# convert from the subtype to the supertype.
Expand Down Expand Up @@ -936,7 +959,8 @@ def _impl_default_for_struct(self, struct: ir.Struct) -> None:
for field in struct.all_fields:
name = self.field_name(field)
value = self._default_value(field)
self.emit(f'{name}: {value},')
attr = '#[allow(deprecated)] ' if field.deprecated else ''
self.emit(f'{attr}{name}: {value},')

@contextmanager
def _impl_struct(self, struct: ir.Struct) -> Iterator[None]:
Expand All @@ -955,13 +979,15 @@ def _emit_new_for_struct(self, struct: ir.Struct) -> None:
'Self',
access='pub'):
with self.block(struct_name):
for field in struct.all_required_fields:
# shorthand assignment
self.emit(f'{self.field_name(field)},')
for field in struct.all_optional_fields:
for field in struct.all_fields:
name = self.field_name(field)
value = self._default_value(field)
self.emit(f'{name}: {value},')
attr = '#[allow(deprecated)] ' if field.deprecated else ''
if field in struct.all_required_fields:
# shorthand assignment
self.emit(f'{attr}{name},')
if field in struct.all_optional_fields:
value = self._default_value(field)
self.emit(f'{attr}{name}: {value},')
first = False

for field in struct.all_optional_fields:
Expand All @@ -981,6 +1007,9 @@ def _emit_new_for_struct(self, struct: ir.Struct) -> None:
field_type = field.data_type
value = 'value'

if field.deprecated:
self.emit('#[deprecated]')
self.emit('#[allow(deprecated)]')
with self.emit_rust_function_def(
f'with_{field_name}',
['mut self', f'value: {self._rust_type(field_type)}'],
Expand Down Expand Up @@ -1106,7 +1135,8 @@ def _impl_display(self, typ: ir.DataType) -> None:
any_skipped = False
for variant in variants:
variant_name = self.enum_variant_name(variant)
var_exp = f'{type_name}::{variant_name}'
attr = '#[allow(deprecated)] ' if variant.deprecated else ''
var_exp = f'{attr}{type_name}::{variant_name}'
msg = ''
args = ''
if variant.doc:
Expand Down
51 changes: 40 additions & 11 deletions generator/test.stoneg.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import string
import sys
from _ast import Module
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from typing import Any, Dict, Iterator, Optional, Protocol

try:
Expand Down Expand Up @@ -180,6 +180,8 @@ def _emit_tests(
'{".tag": "other"',
'{".tag": "dropbox-sdk-rust-bogus-test-variant"')

if test_value.is_deprecated():
self.emit('#[allow(deprecated)] // deprecated variant')
with self._test_fn(type_name + test_value.test_suffix()):
self.emit(f'let json = r#"{json}"#;')
self.emit(f'let x = ::serde_json::from_str::<::dropbox_sdk::types::{ns_name}::{rsname}>(json).unwrap();')
Expand Down Expand Up @@ -207,6 +209,8 @@ def _emit_tests(
def _emit_closed_union_test(self, ns: ir.ApiNamespace, typ: ir.Struct | ir.Union) -> None:
ns_name = self.namespace_name(ns)
type_name = self.struct_name(typ)
if any(v.deprecated for v in self.get_enum_variants(typ)):
self.emit('#[allow(deprecated)] // some variants are deprecated')
with self._test_fn("ClosedUnion_" + type_name):
self.emit('// This test ensures that an exhaustive match compiles.')
self.emit(f'let x: Option<::dropbox_sdk::types::{ns_name}::{self.enum_name(typ)}> = None;')
Expand All @@ -215,7 +219,8 @@ def _emit_closed_union_test(self, ns: ir.ApiNamespace, typ: ir.Struct | ir.Union
var_exps = []
for variant in self.get_enum_variants(typ):
v_name = self.enum_variant_name(variant)
var_exp = f'::dropbox_sdk::types::{ns_name}::{type_name}::{v_name}'
note = '/* deprecated */ ' if variant.deprecated else ''
var_exp = f'{note}::dropbox_sdk::types::{ns_name}::{type_name}::{v_name}'
if not ir.is_void_type(variant.data_type):
var_exp += '(_)'
var_exps += [var_exp]
Expand Down Expand Up @@ -327,12 +332,14 @@ def __init__(
test_value: TestStruct | TestUnion | TestPolymorphicStruct,
stone_type: ir.DataType,
option: bool,
deprecated: bool,
) -> None:
self.name = name
self.value = python_value
self.test_value = test_value
self.typ = stone_type
self.option = option
self.deprecated = deprecated

def emit_assert(self, codegen: RustHelperBackend, expression_path: str) -> None:
extra = ('.' + self.name) if self.name else ''
Expand Down Expand Up @@ -375,6 +382,10 @@ def is_serializable(self) -> bool:
# Not all types can round-trip back from Rust to JSON.
return True

def is_deprecated(self) -> bool:
# only appropriate for enum variants
return False

def test_suffix(self) -> str:
return ""

Expand Down Expand Up @@ -411,10 +422,12 @@ def __init__(
field.default if field.has_default else None,
field.data_type,
rust_generator,
reference_impls)
reference_impls,
field.deprecated)
else:
field_value = make_test_field(
field.name, field.data_type, rust_generator, reference_impls, no_optional_fields)
field.name, field.data_type, rust_generator, reference_impls,
no_optional_fields, field.deprecated)
if field_value is None:
raise RuntimeError(f'Error: incomplete type generated: {stone_type.name}')
try:
Expand All @@ -425,7 +438,8 @@ def __init__(

def emit_asserts(self, codegen: RustHelperBackend, expression_path: str) -> None:
for field in self.fields:
field.emit_assert(codegen, expression_path)
with codegen.block('#[allow(deprecated)]') if field.deprecated else nullcontext():
field.emit_assert(codegen, expression_path)

def test_suffix(self) -> str:
if self._no_optional_fields:
Expand Down Expand Up @@ -476,6 +490,9 @@ def has_other_variants(self) -> bool:
or (isinstance(self._stone_type, ir.Union) and not self._stone_type.closed)

def emit_asserts(self, codegen: RustHelperBackend, expression_path: str) -> None:
if self._variant.deprecated:
codegen.emit('#[allow(deprecated)]')

if expression_path[0] == '(' and expression_path[-1] == ')':
expression_path = expression_path[1:-1] # strip off superfluous parens

Expand All @@ -495,6 +512,9 @@ def emit_asserts(self, codegen: RustHelperBackend, expression_path: str) -> None
def is_serializable(self) -> bool:
return not self._variant.catch_all

def is_deprecated(self) -> bool:
return self._variant.deprecated

def test_suffix(self) -> str:
suf = "_" + self._rust_variant_name
if self._no_optional_fields:
Expand Down Expand Up @@ -561,6 +581,7 @@ def test_field_with_value(
stone_type: ir.DataType,
rust_generator: RustHelperBackend,
reference_impls: Dict[str, Module],
deprecated: bool = False,
) -> TestField:
typ, option = ir.unwrap_nullable(stone_type)
inner = None
Expand All @@ -583,7 +604,8 @@ def test_field_with_value(
value,
inner,
typ,
option)
option,
deprecated)


# Make a TestField with an arbitrary value that satisfies constraints. If no_optional_fields is True
Expand All @@ -594,6 +616,7 @@ def make_test_field(
rust_generator: RustHelperBackend,
reference_impls: Dict[str, Module],
no_optional_fields: bool = False,
deprecated: bool = False,
) -> TestField:
rust_name = rust_generator.field_name_raw(field_name) if field_name is not None else None
typ, option = ir.unwrap_nullable(stone_type)
Expand All @@ -611,11 +634,17 @@ def make_test_field(
# Pick the first tag.
# We could generate tests for them all, but it would lead to a huge explosion of tests, and
# the types themselves are tested elsewhere.
if len(typ.fields) == 0:
# there must be a parent type; go for it
variant = typ.all_fields[0]
# Skip over deprecated variants to make the tests better for detecting compat breakage.
fields = list(filter(lambda f: not f.deprecated and not f.catch_all, typ.fields))
if len(fields) == 0:
if len(typ.fields) == 0:
# there must be a parent type; go for it
variant = typ.all_fields[0]
else:
# okay so all variants are deprecated; I guess just go for it
variant = typ.fields[0]
else:
variant = typ.fields[0]
variant = fields[0]
inner = TestUnion(rust_generator, typ, reference_impls, variant, no_optional_fields)
value = inner.value
elif ir.is_list_type(typ):
Expand All @@ -641,7 +670,7 @@ def make_test_field(
value = bytes([0, 1, 2, 3, 4, 5])
elif not ir.is_void_type(typ):
raise RuntimeError(f'Error: unhandled field type of {field_name}: {typ}')
return TestField(rust_name, value, inner, typ, option)
return TestField(rust_name, value, inner, typ, option, deprecated)


class Unregex(object):
Expand Down
Loading