Skip to content

Conversation

@pslacerda1
Copy link
Contributor

This is a continuation of #448.

I test it with the following command line. It doesn't get collected by the default test runner, I guess, because my tests were written in a pytest parlance.

  • PYTHONPATH=~/Desktop/pymol-open-source/testing/ pytest tests/api/commanding.py

It is still missing windows support.

@JarrettSJohnson
Copy link
Member

I'll look closer to these this weekend, but if your tests aren't running move the pytest tests to test_commanding.py. See the other files like test_editing.py for an example.

@pslacerda1
Copy link
Contributor Author

The tests effectively run with the command line provided:

  • PYTHONPATH=~/Desktop/pymol-open-source/testing/ pytest tests/api/commanding.py

But it doesn't mix well with other tests yet.

@JarrettSJohnson
Copy link
Member

Can you try

python testing/testing.py --run testing/tests/api/commanding.py

this is how I normally run individual test files.

@JarrettSJohnson
Copy link
Member

JarrettSJohnson commented Oct 9, 2025

Either way, I would stick with the convention with what we have (separating the files and putting them in files preceding with test_). The CI will run all pytests and unittests.

@pslacerda1
Copy link
Contributor Author

pslacerda1 commented Oct 9, 2025

Oh, I can see better now. Is it better now?

A current problem is the reuse the function name, how to declare a new command with the same name? The old behavior was to override the previous command in favor of the newer cmd.extend call.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds support for declaring new PyMOL commands with type annotations through a new new_command decorator. The change replaces the old declare_command function with enhanced functionality for command argument type parsing and documentation extraction.

  • Introduces a new new_command decorator with improved type annotation support
  • Adds comprehensive test coverage for various command parameter types
  • Implements automatic documentation parsing from function parameters

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
testing/tests/api/test_commanding.py New comprehensive test file for command declaration functionality
testing/tests/api/commanding.py Removes old declare_command tests that are replaced by new test file
modules/pymol/commanding.py Implements new new_command decorator with enhanced type parsing and documentation features
modules/pymol/cmd.py Updates import to use new_command instead of declare_command

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.


def _parse_list_str(value):
return shlex.split(value)
raise pymol.CmdException("Invalid boolean value: %s" % value)
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

The error message should use f-string formatting for consistency with similar errors elsewhere in the function.

Suggested change
raise pymol.CmdException("Invalid boolean value: %s" % value)
raise pymol.CmdException(f"Invalid boolean value: {value}")

Copilot uses AI. Check for mistakes.
except:
found = False
if not found:
raise pymol.CmdException("Union was not able to cast %s" % value)
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

The error message should use f-string formatting for consistency with similar errors elsewhere in the function.

Suggested change
raise pymol.CmdException("Union was not able to cast %s" % value)
raise pymol.CmdException(f"Union was not able to cast {value}")

Copilot uses AI. Check for mistakes.
elif isinstance(type, str):
return str(value)

raise pymol.CmdException(f"Unsupported argument type {type}")
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

The error message is misleading. The variable type here is a type annotation, not a value type. Consider using {type!r} or a more descriptive message like 'Unsupported type annotation {type}'.

Suggested change
raise pymol.CmdException(f"Unsupported argument type {type}")
raise pymol.CmdException(f"Unsupported type annotation {type!r}")

Copilot uses AI. Check for mistakes.
while True:
i += 1
if tokens[i].string == "def":
while tokens[i].string == "(":
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

This loop will run indefinitely if it encounters a '(' token. It should use an if statement instead of while, or include proper bounds checking to prevent infinite loops.

Suggested change
while tokens[i].string == "(":
if tokens[i+1].string == "(":

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This loop will run indefinitely if it encounters a '(' token. It should use an if statement instead of while, or include proper bounds checking to prevent infinite loops.

Maybe the "(" happens not immediately after the "def", in fact there is the function name in between, and I always saw them in the same line. Pretty sure that that tokens[i+1] doesn't suffice to skip the full function name. This is a while cursor is not parenthesis increase i+=1.

Copy link
Contributor Author

@pslacerda1 pslacerda1 Oct 11, 2025

Choose a reason for hiding this comment

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

Oh, I'm wrong. While loop and if clause are equivalent here same there as after "def" always comes the function name and then "(".

I.e. they are the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd be happy if you @JarrettSJohnson could write or ask someone to write some code challenging the current implementation?

name = tokens[i].string
name_line = tokens[i].line
i += 1
while not (tokens[i].type == tokenize.NAME and tokens[i+1].string == ":"):
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

This loop can cause an IndexError when accessing tokens[i+1] if i reaches the end of the tokens list. Add bounds checking: i+1 < len(tokens) to the condition.

Suggested change
while not (tokens[i].type == tokenize.NAME and tokens[i+1].string == ":"):
while i+1 < len(tokens) and not (tokens[i].type == tokenize.NAME and tokens[i+1].string == ":"):

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a read only function. If it is broken, may be easier to rewrite than to fix.

This loop can cause an IndexError when accessing tokens[i+1] if i reaches the end of the tokens list. Add bounds checking: i+1 < len(tokens) to the condition.

I don't know, but seems that if you are inside a function declaration, the current token is a name, and your code is valid Python, the next token sometime it will happen to be a ":".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My mom always says to me to think before to talk. As you can see, she is correct right now as usual.

_self.help_sc.append(name)

inner.__arg_docs = parse_documentation(function)
_self.keyword[name] = [inner, 0,0,',',parsing.STRICT]
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

The spacing is inconsistent with missing space after the first comma. Should be [inner, 0, 0, ',', parsing.STRICT] for better readability.

Suggested change
_self.keyword[name] = [inner, 0,0,',',parsing.STRICT]
_self.keyword[name] = [inner, 0, 0, ',', parsing.STRICT]

Copilot uses AI. Check for mistakes.
@JarrettSJohnson
Copy link
Member

Oh, I can see better now. Is it better now?

A current problem is the reuse the function name, how to declare a new command with the same name? The old behavior was to override the previous command in favor of the newer cmd.extend call.

How often do we see this in the wild, though? Keeping the same behavior sounds fine to me though I could be convinced with alternatives (logging when new_command overrides, etc...)

@pslacerda1
Copy link
Contributor Author

Of course this isn't a real problem! I is relevant only extend and new_command extending... i'll probably simply use unique names.

Is wonderful this Copilot feature, i'm more than amazed. My talent being replaced by a machine, i'm also very worried. Today i'll check into the review.

@pslacerda1
Copy link
Contributor Author

Okay, Copilot it is like smarter and dumber than anyone.

@JarrettSJohnson
Copy link
Member

It can (usually) be wrong about a lot of things. Feel free to just ignore suggestions that you don't agree with, especially if it's easily refutable.

@pslacerda1
Copy link
Contributor Author

pslacerda1 commented Oct 11, 2025

Oh Jarrett, I do my best on PyMOL, i care about it even more than my master thesis which I'm researching. But I'm just a developer finding where I can contribute the most.

(I talk lot of bullshit also, and i'm unfortunately a bit weird)

I want to say that i like your suggestions and considerations.

@JarrettSJohnson
Copy link
Member

Master's thesis is important to focus on!😂 PyMOL will be here for a longer while ;)

@pslacerda1
Copy link
Contributor Author

Did you saw? I just talked nonsense! My thoughts are weaker in english.

I use PyMOL as a tool to study protein-ligand and protein-short peptide systems. Computer predicted hotspots suggests probes where ligands or peptides should overlap in order to enhance biological affinity or binding efficiency to their targets. I use PyMOL at various steps through my XDrugPy plugin.

@pslacerda1
Copy link
Contributor Author

image

This action (merge yours into mine, master to master) remove this pull-request from the list. Is it a Github bug?

@pslacerda1
Copy link
Contributor Author

pslacerda1 commented Oct 14, 2025

My account were shadow banned because of the shared link! I'm back now. Do you think I can move to the command help? Maybe just pretty printing the function signature including docstring and showing on the (HTML?) console? Would this introduce a library?

Edit: would this require a library?

@pslacerda1
Copy link
Contributor Author

@JarrettSJohnson what do you think about the current version? Is it ready to merge?

elif isinstance(type, str):
return str(value)

raise pymol.CmdException(f"Unsupported argument type annotation {type}")
Copy link
Member

Choose a reason for hiding this comment

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

So what are the consequences of a function without proper type annotations? Do they just not run? And if there's a bug in this implementation, is the command also just expected to not run?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is supposed to be comprehensive and support most cases a typical command line plugin require. The cmd.extend would not be deprecated and may be used as a fallback.

If there is some bug in this implementation, we just add more tests and fix it. Did you saw something bugged on this implementation I'm unaware of?


raise pymol.CmdException(f"Unsupported argument type annotation {type}")

def parse_documentation(func):
Copy link
Member

Choose a reason for hiding this comment

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

docstring/annotation. I assume this returns a dict[param_name] = param_comment ?


raise pymol.CmdException(f"Unsupported argument type annotation {type}")

def parse_documentation(func):
Copy link
Member

Choose a reason for hiding this comment

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

Also, given the tests, this only retrieves comments within the def(...) scope? I am not sure if I've ever come across parameter documentation this way--this probably should be noted somewhere.

def func(
    # some value
    x: int): -> int
    return x * x

But

def func(x: int): -> int
    :param x: some value
    return x * x

is what i'm more familiar with.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could also move the docstring for immediately before the declaration, so parameters came after docstring, if you like...

This is very good in my opinion because would tidify docstring with parameter documentation and typing. But tooling would not get it (e.g. integrated documentation in IDEs).

Python isn't the most syntactically flexible language but I like it plasticity enough...

I'll probably drop the documentation for now. But in my opinion we show use some strong convention, so we can generate rich documentation for new style command line plugins.

Comment on lines 657 to 741
# Inner function that will be callable every time the command is executed
@wraps(function)
def inner(*args, **kwargs):
caller = traceback.extract_stack(limit=2)[0].filename

frame = traceback.format_stack()[-2]
caller = frame.split("\"", maxsplit=2)[1]
Copy link
Member

Choose a reason for hiding this comment

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

Having all of this being called every time a command runs makes me a little nervous, especially if these commands are done in a loop. A lot of this info is just redundantly calculated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added an attribute to direct assess the original unwrapped function. It's just cmd.foo.func() instead of cmd.foo(). Both works the same, but the first works faster.

@pslacerda1
Copy link
Contributor Author

I did a toy benchmark and it took 5 seconds of my machine. Pretty fast and enough for me.

import traceback
def c():
    caller = traceback.extract_stack(limit=2)[0].filename
    caller == "asdasodas"
def b():
    for i in range(1_000_000):
        c()
def a():
    b()
if __name__ == "__main__":
    a()

But also introduced an assessor to the original non-wrapped function so plugin developers can reduce any slowness caused because the cmd.new_command decorator. Again, they always can use the non deprecated cmd.extend.

@pslacerda1
Copy link
Contributor Author

@JarrettSJohnson Can we get this PR in 3.2, please? The previous implementation didn't worked well.

Should I recover the "commentstrings"? :-)

@JarrettSJohnson
Copy link
Member

Hi, Pedro, I'm getting ready to merge your PRs, for tests that are failing, can you fix those that are fixable in this PR. Ones that depend on fixes from other PRs can just be commented out and marked as TODO.

@pslacerda1
Copy link
Contributor Author

pslacerda1 commented Nov 11, 2025

I really don't know if there a useful purpose on typing.Any, the idea is that is a generic type that could match arbitrary values. But this one and Optional[] suffers the same problem, under-specification followed by poor implementation (Optional/None has no implementation yet). So we can pretty much say that this PR is an incomplete implementation in this regard.

If you think it is ok, I may dedicate time to finish Optional[] and maybe Any!

Again, any general scheme I didn't thought before can be made by @extend instad of @new_command.

@pslacerda1
Copy link
Contributor Author

Hi @JarrettSJohnson @speleo3, I think we can give a go on this @new_command as is and then we tailor as necessary while us refactor old plugin code in pymol-scripts-repo? What do you think?

@JarrettSJohnson
Copy link
Member

Cannot merge this until your tests pass. Seems like something(s) not compatible with Python 3.10.

@pslacerda1
Copy link
Contributor Author

This code use the package strenum, which provides an implementation of StrEnum while Python 3.11 doesn't come yet.

@JarrettSJohnson
Copy link
Member

JarrettSJohnson commented Nov 12, 2025

Can you provide a workaround for 3.10?

Oh, you patched. One sec.

@pslacerda1
Copy link
Contributor Author

Is it alright? Or better to skip with pytest.mark.skip()?

Currently all tests will run but this one will fail because require Python 3.11 or the package strenum.

@JarrettSJohnson
Copy link
Member

Can you mark it the test with python > 3.10?

@pslacerda1
Copy link
Contributor Author

i'm noob now,
wait just a moment...

@pslacerda1
Copy link
Contributor Author

I'm less noob now. But don't know what is the cause of failure just by observing logs.

@speleo3
Copy link
Contributor

speleo3 commented Nov 12, 2025

Hi @JarrettSJohnson @speleo3, I think we can give a go on this @new_command as is and then we tailor as necessary while us refactor old plugin code in pymol-scripts-repo? What do you think?

Please excuse me if I say that I have mixed feelings about this implementation and don't want to endorse it. I assume it's an improvement over the already existing declare_command code, but still it's repurposing type annotations in a way that might be incompatible with how type annotation support by other tools is evolving. For me, type annotation is for type checkers (mypy) and IDE support (VS Code), and for that it's really powerful.

@pslacerda1
Copy link
Contributor Author

It is fully compatible with mypy! It is just another utility for type annotations, which currently is mostly for type enforcing and checks.

@pslacerda1
Copy link
Contributor Author

pslacerda1 commented Nov 12, 2025

Maybe we should enforce mypy, it is a nice library. There are tools that observes running python programs and does annotation automatically, this could be a good start!

I just checked to be sure:

image

@pslacerda1
Copy link
Contributor Author

pslacerda1 commented Nov 12, 2025

I never read too much of mypy, but I like it. The above/inline comment strings of arguments that would be a bit disruptive in terms of standard tools support. I think it is a win, but your opinion may diverge:

@cmd.new_command
def my_cmd(
  # Docs about argument foo: it ranges a lot
  foo: int = 0,
  # newer PyMOL-only documentation style about float bar numbers.
  bar: float = 0.0 # more docs for bar
):
  pass

I do not think very much of validation and beyond, I think more like a specific case to support modern command line prompts and get correct typing in the developer side instead of parsing everything always. As it is simple MyPy regular code, you may opt to do your own implementation in case this one becomes infeasible. However I see a good coverage of PyMOL-scripts-repo.

Just type enforcing and automatic casting (more like parsing).

@pslacerda1
Copy link
Contributor Author

This commit 3f49c4f is unnecessary. MyPy works just fine.

@JarrettSJohnson
Copy link
Member

Does the above address your concerns? @speleo3

@JarrettSJohnson
Copy link
Member

Maybe we should enforce mypy, it is a nice library. There are tools that observes running python programs and does annotation automatically, this could be a good start!

Why don't we? What benefit does your run-time checking provide that a statically enforced mypy doesn't?

@pslacerda1
Copy link
Contributor Author

Type conversion from text based commands. The user writes with a keyboard.

@pslacerda1
Copy link
Contributor Author

You have it at runtime and statically enforced if you like also.

@pslacerda1
Copy link
Contributor Author

I can tidy better my code if you want, like beautify the following.

        elif issubclass(type, Enum):
            if value in type:
                return type(value)
            else:
                raise pymol.CmdException(f"Invalid value for enum {type.__name__}: {value}")
        
        elif isinstance(type, str):
            return str(value)

But the real thing is that the subset of typing annotations supported by my implementation is fully supported statically by mypy. Of course is also supported at runtime doing type conversion and high level parsing.

You may diverge on the list or tuple implementation, but I think is fine enough also.

Any way, I focused on the function signature like a contract between plugin developers, PyMOL software and command line users. This I propose to be done with mypy based typing annotations.

@pslacerda1
Copy link
Contributor Author

its just type enforcing and data parsing...

if you become fast, we can move to autocompletion, documentation and maybe other features.

Speed... ok (a workaround), mypy strictness.... ok, python 3.10... ok. Would be awesome, but I'm reticent you don't like something about my implementation... if you talk we can maybe manage it.

@JarrettSJohnson
Copy link
Member

Apologies, I can't reply quickly this week because it's Thanksgiving in the US and I'm traveling a bit.

Was mostly waiting for any further objections to the implementation from a previous reply, but haven't heard anything else. We can consider this was experimental for plugins, so I'm not too worried.

@JarrettSJohnson JarrettSJohnson merged commit 9c92399 into schrodinger:master Nov 28, 2025
12 checks passed
@pslacerda1
Copy link
Contributor Author

Happy traveling.

I'll document this experimental feature on the wiki soon.

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.

3 participants