Skip to content
Draft
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
43 changes: 43 additions & 0 deletions changelog.d/20260105_151912_iglosiggio_struct_methods.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!--
A new scriv changelog fragment.

Uncomment the section that is right (remove the HTML comment wrapper).
For top level release notes, leave all the headers commented out.
-->

<!--
### Removed

- A bullet item for the Removed category.

-->
### Added

- Instance method definitios on structure types (`typing.NamedTuple`, `puyapy.Struct` and
`puyapy.arc4.Struct`) are now supported. As with contracts @subroutine will be the compiler hint
for inhibiting or forcing inlining of these methods.

<!--
### Changed

- A bullet item for the Changed category.

-->
<!--
### Deprecated

- A bullet item for the Deprecated category.

-->
<!--
### Fixed

- A bullet item for the Fixed category.

-->
<!--
### Security

- A bullet item for the Security category.

-->
5 changes: 4 additions & 1 deletion scripts/update_typeshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ def update_puya_typeshed(mypy_typeshed: Path, puya_typeshed: Path) -> None:
stdlib / "sys" / "__init__.pyi",
stdlib / "abc.pyi",
# needed for puyapy
# stdlib / "enum.pyi"
# stdlib / "enum.pyi",
stdlib / "importlib" / "machinery.pyi",
stdlib / "importlib" / "_bootstrap.pyi",
stdlib / "_frozen_importlib.pyi",
]

(puya_typeshed / stdlib).mkdir(exist_ok=True, parents=True)
Expand Down
124 changes: 124 additions & 0 deletions src/puyapy/_typeshed/stdlib/_frozen_importlib.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import importlib.abc
import importlib.machinery
import sys
import types
from _typeshed.importlib import LoaderProtocol
from collections.abc import Mapping, Sequence
from types import ModuleType
from typing import Any, ClassVar
from typing_extensions import deprecated

# Signature of `builtins.__import__` should be kept identical to `importlib.__import__`
def __import__(
name: str,
globals: Mapping[str, object] | None = None,
locals: Mapping[str, object] | None = None,
fromlist: Sequence[str] | None = (),
level: int = 0,
) -> ModuleType: ...
def spec_from_loader(
name: str, loader: LoaderProtocol | None, *, origin: str | None = None, is_package: bool | None = None
) -> importlib.machinery.ModuleSpec | None: ...
def module_from_spec(spec: importlib.machinery.ModuleSpec) -> types.ModuleType: ...
def _init_module_attrs(
spec: importlib.machinery.ModuleSpec, module: types.ModuleType, *, override: bool = False
) -> types.ModuleType: ...

class ModuleSpec:
def __init__(
self,
name: str,
loader: importlib.abc.Loader | None,
*,
origin: str | None = None,
loader_state: Any = None,
is_package: bool | None = None,
) -> None: ...
name: str
loader: importlib.abc.Loader | None
origin: str | None
submodule_search_locations: list[str] | None
loader_state: Any
cached: str | None
@property
def parent(self) -> str | None: ...
has_location: bool
def __eq__(self, other: object) -> bool: ...
__hash__: ClassVar[None] # type: ignore[assignment]

class BuiltinImporter(importlib.abc.MetaPathFinder, importlib.abc.InspectLoader):
# MetaPathFinder
if sys.version_info < (3, 12):
@classmethod
@deprecated("Deprecated since Python 3.4; removed in Python 3.12. Use `find_spec()` instead.")
def find_module(cls, fullname: str, path: Sequence[str] | None = None) -> importlib.abc.Loader | None: ...

@classmethod
def find_spec(
cls, fullname: str, path: Sequence[str] | None = None, target: types.ModuleType | None = None
) -> ModuleSpec | None: ...
# InspectLoader
@classmethod
def is_package(cls, fullname: str) -> bool: ...
@classmethod
def load_module(cls, fullname: str) -> types.ModuleType: ...
@classmethod
def get_code(cls, fullname: str) -> None: ...
@classmethod
def get_source(cls, fullname: str) -> None: ...
# Loader
if sys.version_info < (3, 12):
@staticmethod
@deprecated(
"Deprecated since Python 3.4; removed in Python 3.12. "
"The module spec is now used by the import machinery to generate a module repr."
)
def module_repr(module: types.ModuleType) -> str: ...
if sys.version_info >= (3, 10):
@staticmethod
def create_module(spec: ModuleSpec) -> types.ModuleType | None: ...
@staticmethod
def exec_module(module: types.ModuleType) -> None: ...
else:
@classmethod
def create_module(cls, spec: ModuleSpec) -> types.ModuleType | None: ...
@classmethod
def exec_module(cls, module: types.ModuleType) -> None: ...

class FrozenImporter(importlib.abc.MetaPathFinder, importlib.abc.InspectLoader):
# MetaPathFinder
if sys.version_info < (3, 12):
@classmethod
@deprecated("Deprecated since Python 3.4; removed in Python 3.12. Use `find_spec()` instead.")
def find_module(cls, fullname: str, path: Sequence[str] | None = None) -> importlib.abc.Loader | None: ...

@classmethod
def find_spec(
cls, fullname: str, path: Sequence[str] | None = None, target: types.ModuleType | None = None
) -> ModuleSpec | None: ...
# InspectLoader
@classmethod
def is_package(cls, fullname: str) -> bool: ...
@classmethod
def load_module(cls, fullname: str) -> types.ModuleType: ...
@classmethod
def get_code(cls, fullname: str) -> None: ...
@classmethod
def get_source(cls, fullname: str) -> None: ...
# Loader
if sys.version_info < (3, 12):
@staticmethod
@deprecated(
"Deprecated since Python 3.4; removed in Python 3.12. "
"The module spec is now used by the import machinery to generate a module repr."
)
def module_repr(m: types.ModuleType) -> str: ...
if sys.version_info >= (3, 10):
@staticmethod
def create_module(spec: ModuleSpec) -> types.ModuleType | None: ...
else:
@classmethod
def create_module(cls, spec: ModuleSpec) -> types.ModuleType | None: ...

@staticmethod
def exec_module(module: types.ModuleType) -> None: ...
2 changes: 2 additions & 0 deletions src/puyapy/_typeshed/stdlib/importlib/_bootstrap.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from _frozen_importlib import *
from _frozen_importlib import __import__ as __import__, _init_module_attrs as _init_module_attrs
43 changes: 43 additions & 0 deletions src/puyapy/_typeshed/stdlib/importlib/machinery.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import sys
from importlib._bootstrap import BuiltinImporter as BuiltinImporter, FrozenImporter as FrozenImporter, ModuleSpec as ModuleSpec
from importlib._bootstrap_external import (
BYTECODE_SUFFIXES as BYTECODE_SUFFIXES,
DEBUG_BYTECODE_SUFFIXES as DEBUG_BYTECODE_SUFFIXES,
EXTENSION_SUFFIXES as EXTENSION_SUFFIXES,
OPTIMIZED_BYTECODE_SUFFIXES as OPTIMIZED_BYTECODE_SUFFIXES,
SOURCE_SUFFIXES as SOURCE_SUFFIXES,
ExtensionFileLoader as ExtensionFileLoader,
FileFinder as FileFinder,
PathFinder as PathFinder,
SourceFileLoader as SourceFileLoader,
SourcelessFileLoader as SourcelessFileLoader,
WindowsRegistryFinder as WindowsRegistryFinder,
)

if sys.version_info >= (3, 11):
from importlib._bootstrap_external import NamespaceLoader as NamespaceLoader
if sys.version_info >= (3, 14):
from importlib._bootstrap_external import AppleFrameworkLoader as AppleFrameworkLoader

def all_suffixes() -> list[str]: ...

if sys.version_info >= (3, 14):
__all__ = [
"AppleFrameworkLoader",
"BYTECODE_SUFFIXES",
"BuiltinImporter",
"DEBUG_BYTECODE_SUFFIXES",
"EXTENSION_SUFFIXES",
"ExtensionFileLoader",
"FileFinder",
"FrozenImporter",
"ModuleSpec",
"NamespaceLoader",
"OPTIMIZED_BYTECODE_SUFFIXES",
"PathFinder",
"SOURCE_SUFFIXES",
"SourceFileLoader",
"SourcelessFileLoader",
"WindowsRegistryFinder",
"all_suffixes",
]
8 changes: 7 additions & 1 deletion src/puyapy/awst_build/arc4_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,13 @@ def require_arg_name(arg: pytypes.FuncArg) -> str:
)
return arg.name

result = {require_arg_name(arg): arg.type for arg in func_type.args}
if not func_type.args[0].type.is_type_or_subtype(
pytypes.ARC4ClientBaseType, pytypes.ARC4ContractBaseType
):
raise InternalError(f"expected a self parameter for {func_type.name}")
args = func_type.args[1:]

result = {require_arg_name(arg): arg.type for arg in args}
if "output" in result:
# https://github.com/algorandfoundation/ARCs/blob/main/assets/arc-0032/application.schema.json
raise CodeError(
Expand Down
3 changes: 0 additions & 3 deletions src/puyapy/awst_build/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,6 @@ def function_pytype(
):
arg_pytype = type_to_pytype(registry, at, source_location=loc, in_func_sig=True)
func_args.append(pytypes.FuncArg(type=arg_pytype, kind=kind, name=name))
# is the function a method but not a static method? if so, drop the first (implicit) argument
if func_def.info and not func_def.is_static:
_self_arg, *func_args = func_args
return pytypes.FuncType(
name=func_def.fullname,
args=func_args,
Expand Down
5 changes: 4 additions & 1 deletion src/puyapy/awst_build/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from puyapy.models import (
ARC4BareMethodData,
ARC4MethodData,
ArgKind,
ContractClassOptions,
ContractFragmentBase,
ContractFragmentMethod,
Expand Down Expand Up @@ -330,6 +331,7 @@ def visit_function(
ctx,
func_def=func_def,
source_location=source_location,
is_method=True,
inline=inline,
contract_method_info=ContractMethodInfo(
fragment=self.fragment,
Expand Down Expand Up @@ -440,9 +442,10 @@ def add_stub_method(
*,
return_type: pytypes.RuntimeType = pytypes.BoolType,
) -> None:
self_arg = pytypes.FuncArg(pytypes.ContractBaseType, "self", ArgKind.ARG_POS)
self.symbols[name] = pytypes.FuncType(
name=".".join((self.id, name)),
args=(),
args=(self_arg,),
ret_type=return_type,
)
implementation = awst_nodes.ContractMethod(
Expand Down
30 changes: 29 additions & 1 deletion src/puyapy/awst_build/eb/arc4/struct.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from puya import log
from puya.awst import wtypes
from puya.awst.nodes import Copy, Expression, FieldExpression, NewStruct
from puya.awst.nodes import Copy, Expression, FieldExpression, NewStruct, SubroutineID
from puya.parse import SourceLocation
from puyapy import models
from puyapy.awst_build import pytypes
Expand All @@ -23,6 +23,10 @@
from puyapy.awst_build.eb.arc4._base import ARC4FromLogBuilder
from puyapy.awst_build.eb.factories import builder_for_instance
from puyapy.awst_build.eb.interface import BuilderComparisonOp, InstanceBuilder, NodeBuilder
from puyapy.awst_build.eb.subroutine import (
BoundSubroutineInvokerExpressionBuilder,
SubroutineInvokerExpressionBuilder,
)
from puyapy.awst_build.utils import get_arg_mapping

logger = log.get_logger(__name__)
Expand Down Expand Up @@ -68,6 +72,15 @@ def member_access(self, name: str, location: SourceLocation) -> NodeBuilder:
case "from_log":
return ARC4FromLogBuilder(location, self.produces())
case _:
pytype = self.produces()
method = pytype.static_methods.get(name) or pytype.methods.get(name)
if method:
return SubroutineInvokerExpressionBuilder(
target=SubroutineID(method.name),
func_type=method,
location=location,
)

return super().member_access(name, location)


Expand All @@ -89,6 +102,21 @@ def member_access(self, name: str, location: SourceLocation) -> NodeBuilder:
source_location=location,
)
return builder_for_instance(field, result_expr)
case method_name if method := self.pytype.methods.get(method_name):
return BoundSubroutineInvokerExpressionBuilder(
target=SubroutineID(method.name),
func_type=method,
location=location,
args=[self],
arg_names=[None],
arg_kinds=[models.ArgKind.ARG_POS],
)
case method_name if static_method := self.pytype.static_methods.get(method_name):
return SubroutineInvokerExpressionBuilder(
target=SubroutineID(static_method.name),
func_type=static_method,
location=location,
)
case "copy":
return CopyBuilder(self.resolve(), location, self.pytype)
case "_replace":
Expand Down
20 changes: 8 additions & 12 deletions src/puyapy/awst_build/eb/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
)
from puyapy.awst_build.eb.subroutine import (
BaseClassSubroutineInvokerExpressionBuilder,
SubroutineInvokerExpressionBuilder,
BoundSubroutineInvokerExpressionBuilder,
)
from puyapy.models import ContractFragmentBase

Expand Down Expand Up @@ -56,18 +56,11 @@ def call(

@typing.override
def member_access(self, name: str, location: SourceLocation) -> NodeBuilder:
sym_type = self.fragment.resolve_symbol(name)
if sym_type is None:
func_type = self.fragment.resolve_symbol(name)
if func_type is None:
return super().member_access(name, location)
if not isinstance(sym_type, pytypes.FuncType):
if not isinstance(func_type, pytypes.FuncType):
raise CodeError("static references are only supported for methods", location)
func_type = attrs.evolve(
sym_type,
args=[
pytypes.FuncArg(type=self.produces(), name=None, kind=models.ArgKind.ARG_POS),
*sym_type.args,
],
)
method = self.fragment.resolve_method(name)
if method is None:
raise CodeError("unable to resolve method member", location)
Expand Down Expand Up @@ -98,10 +91,13 @@ def member_access(self, name: str, location: SourceLocation) -> NodeBuilder:
if sym_type is None:
raise CodeError(f"unrecognised member of {self.pytype}: {name}", location)
if isinstance(sym_type, pytypes.FuncType):
return SubroutineInvokerExpressionBuilder(
return BoundSubroutineInvokerExpressionBuilder(
target=InstanceMethodTarget(member_name=name),
func_type=sym_type,
location=location,
args=[self],
arg_names=[None],
arg_kinds=[models.ArgKind.ARG_POS],
)
else:
storage = self._fragment.resolve_storage(name)
Expand Down
Loading
Loading