Skip to content

feat!: Support static methods on types - #1699

Open
GBoydQ wants to merge 19 commits into
Quantinuum:mainfrom
GBoydQ:gb/add_classfunctions
Open

feat!: Support static methods on types#1699
GBoydQ wants to merge 19 commits into
Quantinuum:mainfrom
GBoydQ:gb/add_classfunctions

Conversation

@GBoydQ

@GBoydQ GBoydQ commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Adds staticmethods to guppy, supporting structs, enums and protocols.

Functions are checked for staticness during parsing, and ParsedDefinitions now have an is_static flag.

ExprSynthesizer.visit_Attribute contains logic for calling staticmethods on instantiated types (we need to not partially apply self) and Names both for globals and generic parameters.

Comptime staticmethods are not yet supported, and work still needs to be done to support Self output for staticmethods generally.

LLM disclosure: commit 1e3486b to refactor the location of determine_static was generated by LLM and checked by me, any mistakes are my own.

BREAKING CHANGE:
remove staticmethod from unsupported

Closes #694

@GBoydQ
GBoydQ requested a review from a team as a code owner April 28, 2026 16:18
@GBoydQ
GBoydQ requested a review from maximilianruesch April 28, 2026 16:18
@maximilianruesch maximilianruesch changed the title feat: staticmethods feat: Support static methods on opaque types Apr 29, 2026
@maximilianruesch maximilianruesch changed the title feat: Support static methods on opaque types feat: Support static methods on types Apr 29, 2026
@maximilianruesch maximilianruesch changed the title feat: Support static methods on types feat!: Support static methods on types Apr 29, 2026

@maximilianruesch maximilianruesch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR, this looks really interesting. I have a bunch of suggestions. I will also request @mark-koch to provide his comments on this PR!

Comment on lines +105 to +108
@dataclass(frozen=True)
class ImplDefinition:
id: DefId
is_static: bool

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this it is useful to inherit from NamedTuple (and remove the decorator), since you do not need the full blown features of a dataclass. It gives you lower runtime overhead and virtually the same attribute access.



@dataclass(frozen=True)
class ImplDefinition:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let us call this TypeMember or TypeMemberDef. Impl... was the previous wording in this file, which was confusing.

Comment thread guppylang/src/guppylang/decorator.py Outdated
Comment on lines +249 to +251
if isinstance(val, GuppyDefinition) and hasattr(
val.wrapped, "python_func"
):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things:
Instead of silently failing to register if the wrapped definition has no python_func, let us fail in case it is a GuppyDefinition but we cannot determine whether the function is static or not. If the user can run into this case, it should be a proper error with a diagnostic (and an error test), if not, it can be a InternalGuppyError. This will help us debug / discover new cases where we introduce members that are GuppyDefinition, but have no function attached (for whatever reason).
We discourage hasattr checks, since they are dependent on "implementation-ish" details (characterising a class by which attribute is present), and are not hardened against refactoring changes. Furthermore, from here it is completely invisible which classes may have python_func. Instead, you should use isinstance checks to check all classes that you want to cover explicitly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes the hasattr is not ideal, the problem I was facing here was that I think I originally had isinstance(..., GuppyFunctionDefinition) but mypy was not able to determine that the wrapped Definition actually contained a python_func in that case. Is there a way to ensure a GuppyFunctionDefinition contains a function?

@maximilianruesch maximilianruesch Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GuppyFunctionDefinition is a subclass of GuppyDefinition, and is not the wrapped member. What you are searching for is RawFunctionDef and other similar classes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you this was very helpful. Logic for different FunctionDef types is now wrapped up in determine_static

Comment thread guppylang/src/guppylang/decorator.py Outdated
Comment on lines +299 to +301
if isinstance(val, GuppyDefinition) and hasattr(
val.wrapped, "python_func"
):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread guppylang/src/guppylang/defs.py Outdated
Comment on lines +107 to +108
# We can only access variants of the enum from the enum class
# and staticmethods

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For one, this comment should be on the line with the name in variants OR is a static member clause, and for another it is worded confusingly: Right now it sounds like we can access variants from [either the enum class or static methods], which does not make sense.

Instead, let us say something like

# We can only access enum variants or static methods from the enum class

Comment thread uv.lock

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really have a CI check for uv.lock synchronisation.

@guppy
def main() -> None:
t = Test.default(1.0)
# can call Test.default[int] from Test[float] instance

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit counterintuitive, but reasonable in this case. Are there cases where this is not allowed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does seem odd but I think it makes sense, as I would expect the behaviour of staticmethods to be to ignore the instance.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should extend the comment by saying "as static methods ignore instance types".

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we test static guppy.declares? Perhaps with library linking?

On that note, you should check whether link name inference still works with static methods (in the link_name tests).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added a test for library linking and another one to the link_name tests.

Comment on lines +328 to +330
# get the wrapped function if a staticmethod
if isinstance(f, staticmethod):
f = f.__func__

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that the saved self.python_func is wrapped may be breaking as well (depending on if the wrapper copies or forwards key attributes such as __module__.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mark-koch and I briefly discussed this but I would appreciate any more thoughts / discussion to make sure this is ok

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently there is no guarantees made by the python_func about its type (because we do not have a proper type for it). However we do access things like __module__ on it, which is defined with regular functions. I guess if you add the link_name tests (and any other paths that may trigger attribute accesses) we should be fine.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider storing the wrapped function directly instead of unwrapping here. That might be cleaner?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not 100% sure what you mean here.
Are you saying that on decoration we should instead store .__func__ so that the stored py_func is always a function?
I can change this, although it would also require reworking determine_static as I'm not sure there would still be a way to check if a function is static without the decorator being stored in the Definition.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we can probably do is let the RawFunctionDef take the wrapped function, and upon calling parse, the function is determined to be static or not (and this information will be available on the ParsedFunctionDef, perhaps via a new property, and unwrapped once (if appropriate) to be passed into parse_py_func.

@mark-koch is that roughly what you envisioned? I see no cleaner way to lift the static check without adding factory methods for RawFunctionDef and replacing all of its instantiations.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think the best place to store whether the function was static or not would be inside GuppyFunctionDefinition. That is the object we have at hand when you try to register functions in the DEF_STORE which is the point at which you need to know if it's static or not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok fair enough, the reason I did not do this initially was that I didn't want method information to be stored in functions that are not methods, but in hindsight it would probably simplify things a bit.

Comment on lines +586 to +598
if node.value.id in self.ctx.globals:
defn = cast("ParsedDef", self.ctx.globals[node.value.id])
if not isinstance(defn, PythonObject):
ty_def = ENGINE.parsed[defn.id]
if (
node.attr in DEF_STORE.type_members[ty_def.id]
and isinstance(ty_def, TypeDef)
and (func := ENGINE.get_instance_func(ty_def, node.attr))
and DEF_STORE.type_members[ty_def.id][node.attr].is_static
):
return with_loc(
node, GlobalName(id=node.attr, def_id=func.id)
), func.ty

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this fail when the thing that we are accessing on is not in globals? Can this even happen? I am confused what this does, and what all the if clauses mean. Perhaps @mark-koch has thoughts on this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could definitely be cleaner / wrapped up into a function.
Also the cast / isinstances were also a result of my fight with mypy due to my lack of experience with the compiler types.
Essentially I was going for, "if this is a type that has has a type_member of this name and it's static, then return the staticmethod"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused in particular about the if not isinstance(defn, PythonObject):. What does it do?

Also, I guess ENGINE.get_instance_func(...) is not necessarily the right name anymore.

Let us wait for a comment from Mark on this one.

Comment on lines +587 to +588
defn = cast("ParsedDef", self.ctx.globals[node.value.id])
if not isinstance(defn, PythonObject):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defn = cast("ParsedDef", self.ctx.globals[node.value.id])
if not isinstance(defn, PythonObject):
defn_or_python_object = self.ctx.globals[node.value.id]
if isinstance(defn_or_python_object, TypeDef):

node.attr in DEF_STORE.type_members[ty_def.id]
and isinstance(ty_def, TypeDef)
and (func := ENGINE.get_instance_func(ty_def, node.attr))
and DEF_STORE.type_members[ty_def.id][node.attr].is_static

@mark-koch mark-koch Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually need this static check? Imo type.func would also be fine if func is not static, similar to Python:

x = int.__add__(1, 2)

if node.value.id in self.ctx.globals:
defn = cast("ParsedDef", self.ctx.globals[node.value.id])
if not isinstance(defn, PythonObject):
ty_def = ENGINE.parsed[defn.id]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should already be ParsedDef?

ty_def = ENGINE.parsed[defn.id]
if (
node.attr in DEF_STORE.type_members[ty_def.id]
and isinstance(ty_def, TypeDef)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isinstance(ty_def, TypeDef) could be removed if you go with the refactor above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to make most of the changes in this area but this one didn't work

Comment on lines +669 to +671
if (
impl_def := DEF_STORE.type_members[ty_id].get(node.attr)
) and impl_def.is_static:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be clearer if we introduced a new ENGINE.get_type_member function that returns the TypeMember:

if member := ENGINE.get_type_member(ty, node.attr):
    if member.is_static:
        ...
    else:
        ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have introduced this although I still need to use ENGINE.get_instance_func or ENGINE.get_parsed and then cast / check the type is a CallableDef.
I could wrap up get_instance_func and get_type_member into one function that returns both if that would be helpful.

Comment on lines +180 to +186
# comptime methods not yet supported
case RawTracedFunctionDef():
if isinstance(defn.wrapped.python_func, staticmethod):
# TODO guppy error handling
raise TypeError("static comptime func")
else:
return False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the issue with comptime functions, I'd assume everything should just work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not looked into this, but I get 'staticmethod' object has no attribute '__globals__' in mock_builtins

Comment on lines +191 to +192
if not isinstance(func_def, RawFunctionDef):
return False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could also be a RawCustomFunctionDef, RawFunctionDecl, RawTracedFunctionDef, or even another OverloadedFunctionDef

Comment on lines +194 to +197
# TODO guppy error handling
raise TypeError(
"one of the functions in this overload is not static"
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a vanilla Python error is ok here since it raised while decorating, not during compilation.

However, the message could be improved a bit, for example mentioning which overload is static and which isn't. Also could you add an error test for this case?

else:
return False
case OverloadedFunctionDef():
# check all the methods in the overload are also static

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current implementation doesn't do that. You just check if any overload is static and then raise an error? I would expect something like

is_static = [determine_static(overload_id) for overload_id in defn.wrapped.func_ids]
if all(is_static):
    return True
elif not any(is_static):
    return False:
else:
    raise TypeError("Some static, some not")

Note that this would refquire refactoring determine_static to take ids instead of GuppyDefinitions

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or, maybe we should just look for @staticmethod on the @guppy.overloaded function instead?

Comment on lines +328 to +330
# get the wrapped function if a staticmethod
if isinstance(f, staticmethod):
f = f.__func__

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider storing the wrapped function directly instead of unwrapping here. That might be cleaner?

@GBoydQ

GBoydQ commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Apologies for this becoming stale, I have reworked this for v1 and also made some small changes to support protocols.
Other than that, I had to move some things round due to restructuring of the codebase (in particular I could not find a good place for determine_static that did not cause circular imports).

I am happy to either move the gb/add_classmethods branch onto these new changes or to create a new PR, whichever is preferable.

The only piece of review feedback I have not addressed is "We could consider storing the wrapped function directly instead of unwrapping here" because I wanted some clarification there.

@maximilianruesch

maximilianruesch commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

@GBoydQ no worries!

I am happy to either move the gb/add_classmethods branch onto these new changes or to create a new PR, whichever is preferable.

Surely you mean your current branch gb/add_classfunctions. But I would suggest you move your current branch to the new changes, if the current reviews would still apply. If they do not, and you would prefer a fresh review (that is unbiased by existing reviews) you may open a new PR. But it does not sound like that is the case here.

The only piece of review feedback I have not addressed is "We could consider storing the wrapped function directly instead of unwrapping here" because I wanted some clarification there.

I will shortly comment with some clarifications. Edit: See #1699 (comment).

@GBoydQ
GBoydQ force-pushed the gb/add_classfunctions branch 2 times, most recently from d27f852 to 1a75c10 Compare July 28, 2026 08:46
@GBoydQ GBoydQ changed the title feat!: Support static methods on types feat: Support static methods on types Jul 28, 2026
@GBoydQ GBoydQ changed the title feat: Support static methods on types feat!: Support static methods on types Jul 28, 2026
@maximilianruesch maximilianruesch changed the title feat!: Support static methods on types feat: Support static methods on types Jul 28, 2026
@maximilianruesch maximilianruesch changed the title feat: Support static methods on types feat!: Support static methods on types Jul 28, 2026
@maximilianruesch

Copy link
Copy Markdown
Collaborator

@GBoydQ I would not classify this as a breaking change. Adding a feature which was previously unsupported is not breaking, since previous user code did not compile anyways. Furthermore, users should not rely on features remaining to be unsupported.

@GBoydQ

GBoydQ commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@GBoydQ I would not classify this as a breaking change. Adding a feature which was previously unsupported is not breaking, since previous user code did not compile anyways. Furthermore, users should not rely on features remaining to be unsupported.

I agree, however semver complained at me about this when I removed the previous breaking change by addressing your comments, I will look into it

@maximilianruesch

Copy link
Copy Markdown
Collaborator

We recently adjusted the breaking changes check to not warn on breaks in the guppylang_internals interface anymore, since we consider every version bump of that package breaking. Thus, ! now actually corresponds to behavioural breaks that a user of only guppylang can observe.

@GBoydQ
GBoydQ force-pushed the gb/add_classfunctions branch from 1a75c10 to 81156f5 Compare July 29, 2026 16:07
@GBoydQ

GBoydQ commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

I have refactored to instead determine staticness during parsing and store it on parsed definitions as you've suggested, and also added support for comptime staticmethods.

The remaining failing test I had initially thought was a type inference error similar to ones that I had seen 'in the wild', but now think that it may be due to the fact that I am not calling _instantiate_self on protocol staticmethods.

I was experimenting with Self args a little (kind of necessary for protocol staticmethods to be useful), but I would prefer not to make this PR any larger + I do not understand func_checker and protocol_checker well enough to know the consequences of changes I would make there.

I would appreciate your thoughts, one possible option for now might be to not support staticmethods on protocols with type args.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support static methods for types

3 participants