-
-
Notifications
You must be signed in to change notification settings - Fork 106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Provide alternate syntax for registering LSP feature #263
Comments
Can you provide a concrete use case for why you would want to register on the class rather than the instance? |
The server I want to write requires a database to pull data from. To set this up, I want to pass a path to the database on init of the server object so that the class can always guarantee the database it is connected to is valid. Because of this, the reference to the database (and other derived things being stored on the class) should be private members so that the area of the code that instantiates the server cannot modify it. In order to modify state on the language server instance in a global function, the state in the server object must be publicly accessible: class MyLanguageServer(LanguageServer):
def __init__(self, db_path: str, db: sqlite3.Connection):
self._db_path = db_path
self._db = db
super().__init__()
@feature(TEXT_DOCUMENT_DID_OPEN)
def did_open(self, params: DidOpenTextDocumentParams):
# If this was global, it wouldn't be able to safely access `self._db`
result = self._db.execute("SELECT * FROM MyTable")
... For efficiency and simplicity, I want to store several things from the database in caches in the class. Making them all public would imply that at the call-site it is appropriate to modify any of the attributes independently of each other, which could leave the class in an inconsistent state. The cleanest way of using the feature decorator on an instance in this system that I can think of is this: class MyLanguageServer(LanguageServer):
def __init__(self, db_path: str, db: sqlite3.Connection):
self._db_path = db_path
self._db = db
super().__init__()
def did_open(self, params: DidOpenTextDocumentParams):
# If this was global, it wouldn't be able to safely access `self._db`
result = self._db.execute("SELECT * FROM MyTable")
...
def create_server(db_path: str, db: sqlite3.Connection):
server = MyLanguageServer(db_path, db)
@server.feature(TEXT_DOCUMENT_DID_OPEN)
def did_open(ls: MyLanguageServer, params: DidOpenTextDocumentParams):
ls.did_open(params)
return server
if __name__ == "__main__":
... # Commandline setup
args = parser.parse_args()
with sqlite3.connect(args.db) as conn:
server = create_server(args.db, conn)
... Which isn't very nice and means that if the code has multiple branches/functions to create the actual server instance then each one needs to define the features individually because they are bound to the instance instead of the class. |
Apologies for the late reply. I think this sounds very reasonable. Considering there are workarounds I don't think this will be high priority, but I can certainly see how it would help clean up the code, so it is at least a priority. I don't know the internals well enough to be sure, but I can't think of any downsides to this. Anything springs to mind? |
I've just come across another downside to the current approach, namely that it doesn't take advantage of the ability to assert the RPC expectations with types. I'm not a Python aficionado, so I'm wondering about an approach like this: class LanguageServer:
def __init__(self):
# Loop over methods in `self.methods` to find features to enable.
# `LanguageServer.methods` (therefore the methods below) is a
# collection of all the features that can be enabled, but just as
# prototypes expressing the argument and return types.
# Can be done with something like:
# `if type(self).method == Base.method:` ...
def did_open(self, params: DidOpenTextDocumentParams):
pass
def completion(self, params: CompletionParams) -> CompletionList:
return []
... # All the available features
class CustomLanguageServer(LanguageServer):
def did_open(self, params: DidOpenTextDocumentParams):
CustomFunctionality(params)
def completion(self, params: CompletionParams) -> CompletionList:
return CustomCompletions(params) I don't think there's a way to enforce those types when they're overridden? At the very least, it would mean you could just copy and paste the method bodies into your own But the point is that we could kill a few birds with one stone, so to speak. Firstly, I assume it solves the original need of this issue? Secondly, considering that we have an upcoming opportunity to make breaking changes in #273, it's at least worthwhile thinking about ideal solutions, now that Pygls is more mature. Though making this kind of change to feature definitions doesn't have to be a breaking change, it can also live alongside the existing method with a log warning about deprecation. Thirdly, #273 is mainly about introducing better support for types, so having a new way to define server features that encourages typing will compliment it. |
One advantage of the current decorator approach, is that it provides a place to provide additional options when registering methods. For example, specifying trigger characters for completion @server.feature(
COMPLETION,
CompletionOptions(
trigger_characters=[">", ".", ":", "`", "<", "/"], resolve_provider=True
),
)
def on_completion(ls: LanguageServer, params: CompletionParams):
... The given It's a use case we'd have to keep in mind when designing any alternate syntaxes - maybe a solution would simply be to require a class CustomLanguageServer(LanguageServer):
completion_options = CompletionOptions(trigger_characters=[">", ".", ":", "`"])
def completion(self, params: CompletionParams) -> CompletionList:
return CustomCompletions(params) Another thing worth mentioning is that method implementations can come in three varieties, synchronous functions, async functions and functions that run in a separate thread (currently registered via |
Good points. I'd completely overlooked them. I wonder if a default keyword arg would work for those extra completion options? It could be inspected with Good to be thinking about it all though. |
Just want to make a note about something else that has been frequently popping into my mind regarding how we register features. No matter which approach we end up going with, I think it would be helpful to explicitly express whether a feature is a notification or a request. So for example: @server.feature.notification(TEXT_DOCUMENT_DID_OPEN)
def did_open():
...
@server.feature.request(COMPLETION)
def completion():
... Requests and notifications (and maybe commands is a 3rd type?) have slightly different behaviours and significantly different code paths. The most notable is that requests require direct responses, whilst notifications do not, indeed the spec states that any direct responses to a notification should be ignored by the client. Whilst the current lack of distinction is not critical, I think the self-documenting requirement for stating the nature of the feature will help manage expectations and the ability to better reason about behaviour. Anyway, just an idea I wanted to note down. Edit: The thing that actually led me to this idea was that Pygls does not currently have a centralised way to handle all errors. Namely that errors from notifications are merely logged in the server and not expressed back to the client. |
Even with the decorator method, we can probably figure out a way for it to work with a class method. As an example, the python property decorator is quite magical in this regard and might be useful as in inspiration: class C:
@property
def foo(self):
...
@foo.setter
def foo(self, new_value):
... Conceptually, maybe something like
The Probably it can be made even more smooth than this; like maybe we can have Let me know if this is something that might be interesting and I can look into writing a patch/PR for it. I, like OP, also had a case where I wanted to do a custom subclass and initialize somethings in the |
If it's possible to make that work, I'd definitely find that interesting :) |
I think at its core it would be the following: from functools import wraps
class hybridmethod(object):
def __init__(self, func):
self.func = func
def __get__(self, obj, cls):
context = obj if obj is not None else cls
@wraps(self.func)
def hybrid(*args, **kw):
return self.func(context, *args, **kw)
# optional, mimic methods some more
hybrid.__func__ = hybrid.im_func = self.func
hybrid.__self__ = hybrid.im_self = context
return hybrid
class LanguageServer:
...
@hybridmethod
def feature(self_or_cls, ...):
# We could also have the wrapper inject custom flag to separate (etc) instead of using `isinstance`
if isinstance(self_or_cls, LanguageServer):
print("Called with instance")
# Existing logic here
else:
print("Called as class method")
# New logic here
# Tests
# - This prints "Called with instance"
LanguageServer("foo", "v1.0").feature()
# - This prints "Called as class method
LanguageServer.feature() The "harder" part is how to handle input the data in the classmethod variant, because we do not have an "instance" to hold the data and we may have to "Hold" the data and wait with acting on it until |
That looks nice!
Actually, the data isn't directly held by the server, the Then again... that might just create more problems - how would the various components that need it, get access the underlying instance? 🤔 Since the state is already separated, I imagine it's a solvable problem but might require a non-trivial re-wiring of the internals. Also worth mentioning that if/when #418 lands, while most of the major components of |
Reviewing the situation a bit more, this basically ends up being a variant of Then the initialization that reads the Is there agreement on this approach? If so, then I am might have look at doing a PR for that. |
I think that makes sense, as long as both the current approach and the new syntax work I can't see there being an issue :) |
It sounds great! Though I'm afraid I've lost my insider knowledge on this area, as I haven't worked with this part of the code in a while. So I can't give any insightful technical feedback. |
Hi, I have now taken a stab at implementing this feature. The conceptual work is at nthykier#3. I have not opened a PR against this repo yet, since I would like to discuss the concept a bit before I have you do detailed code review (and I spend more time with the polish). First off, the PR works expected, you can do:
(See https://github.com/nthykier/pygls/blob/6f1d7c21d4b0724d0397dbbcc3e9e8b4ad881011/tests/test_class_registration.py as a concrete example). So far so good. The part that I feel is a bit sub-optimal is that linters and type checkers do not understand I am inclined to say we should strongly reconsider this part of the approach and replace it with a separate callable that does not trigger confusing in all existing tools. It could be as simple as using @tombh @alcarney, could I have you chime in here on the direction you would like to go? :) |
That's great you've got something working! This is some pretty complicated meta-language stuff, so hats off. I'd need more time to really review this, but it seems reasonable so far. I think Alex might have more insight. The multiple I don't fully understand the confusion that linters/type checkers get with |
I think I agree, having separate decorators for class-based and instance-based registration makes a lot of sense, especially if it removes the "magic" that confuses the linters. I'm not the biggest fan of the global state in Would replacing def _register_class_based_features(self):
"""Registers generic LSP features from this class."""
for name in self.lsp_user_methods:
... with def _register_class_based_features(self):
"""Registers generic LSP features from this class."""
for name in dir(self):
... remove the need for Overall, I think it looks really promising! It doesn't look too dis-similar to what I was experimenting with in this commit for pygls' built-in methods. |
Thanks. :)
If it is the multiple calls, then we might be able to wrap them in a decorator or so. Though however we slice it, it is an internal pre-start hook
Linters basically look at methods and look for
The alternative would be to have two methods; one for the instance based call (the |
I agree, if I could do it without global state, I would.
I started with that. It broke my consumer code because I had attributes (
That can lead to some funky debugging sessions that I would rather not expose our uses too, since the "during initialization" the object might not be in a stable state. As said, I broke my own code with this because I had "safety checks" to ensure certain properties were not called, before a delayed initialization had happened. The One solution would be to move the
Glad to hear it. :) |
Related, even when fully converting to the class-based setups, the consumer might need to initialize some of their attributes before the |
Thanks for the explanation, I thought I was missing something :) While mildly disappointing, the global state is not a deal breaker for me since it's something you could work around if needed to.
I agree with you, it's more important to prevent issues like those. |
On a side note, maybe there is some room to support a pattern that I saw being used in esbonio (@alcarney I really like it, this language server project probably deserves more recognition), that I'm also using in a ongoing attempt to implement a LS: Taking the example of |
Currently, the supported method of registering features in a
LangaugeServer
is through the@server.feature()
decorator. This works well for a lot of use-cases, but the standard usage relies on instantiating a globalLanguageServer
object, therefore making it difficult to create a customLanguageServer
which comes with registered functionality on instantiation.The current pattern for registering features is as follows:
In order to register register a feature, the
server
object must already be instantiated, because it is a bound method on the instance ofMyLanguageServer
instead of a global decorator, which could be used anywhere. This means that this pattern is impossible:Note that it is also invalid syntax to write
@self.feature(...)
,@feature(...)
or@MyLanguageServer.feature(...)
because none of them are in scope there.There is a workaround for this due to how decorators are implemented in Python, but it is quite ugly and more confusing than necessary:
Would it be possible to add a syntax to register features that are methods on classes? I'm looking for something along the lines of:
Or alternatively, a simplified way of registering features at runtime, along the lines of:
The text was updated successfully, but these errors were encountered: