Skip to content
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

chore: static analysis hack for globals #12824

Merged
merged 1 commit into from
Apr 16, 2021
Merged

Conversation

ankush
Copy link
Member

@ankush ankush commented Apr 9, 2021

Since frappe.db is dynamically defined static code analyzers can't understand what it is. typing.TYPE_CHECKING can be used to instructs static code analyzers what to do with such globals.

Reference: https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING

Outcome: Depending on your editor/config now frappe.db.* should auto-complete, jump to the definition and give docstrings on hover etc.

Note:

  • this is NOT py2 compatible.
  • Comment at end of if-block is intentional. This is to avoid accidentally leaking some code outside of if block.
  • I've only added frappe.db for now, more can be added if required.
Screen.Recording.2021-04-09.at.9.35.01.PM.mov

@ankush ankush requested review from a team and prssanna and removed request for a team April 9, 2021 16:21
@Alchez
Copy link
Contributor

Alchez commented Apr 10, 2021

@ankush, shouldn't this detect the active db type (mariadb or postgres), and import the correct controller accordingly?

@ankush
Copy link
Member Author

ankush commented Apr 10, 2021

@Alchez I do not think it's possible to detect it, as it comes from config files and static analyzers won't actually run the code to find the right database type. Alternate is to assign db to base class Database.

ref:

local.db = get_db(user=db_name or local.conf.db_name)

local.conf = _dict(get_site_config())

@Alchez
Copy link
Contributor

Alchez commented Apr 10, 2021

static analyzers won't actually run the code to find the right database type

Static analyzers don't execute TYPE_CHECKING code at runtime, it's true, but I think they do get executed during analysis. So they would be able to retrieve info from config files.

Alternate is to assign db to base class Database.

This is also a way, but you'll miss out on the other controller methods that may not exist in Database.

@ankush
Copy link
Member Author

ankush commented Apr 11, 2021

Pyright does not evaluate basic string comparison:
image

VS code with Jedi evaluates basic string comparison but doesn't evaluate config:
image

@ankush
Copy link
Member Author

ankush commented Apr 11, 2021

@Alchez see updated commit. Thanks for pointing it out, I think from type analysis perspective it is better to define type as Union of both. So code can be checked for compatibility against both types.

In editors, the behaviour of this is kinda dependent on implementation of the editor, some would just jump to first/common definition and some will ask user for picking between two implementations. Anyway, I feel this is better than hardcoding mariadb as default db type.

@Alchez
Copy link
Contributor

Alchez commented Apr 11, 2021

@ankush Agreed, union should be good enough. The dev can then go in and see all references.

@kisg
Copy link

kisg commented Apr 14, 2021

Why don't you just use a standard type annotation like this:

from frappe.database import Database
db : Database = local("db")

I think that it is a dangerous pattern for application code to call methods defined in either MariaDBDatabase or PostgresDatabase on frappe.db instead of the real interface defined in Database. If database specific functionality is required, then that should be done like this:

T = TypeVar('T', bound='Database')

class Database:
    def get_impl(self: T) -> T:
        return self


frappe.db.get_impl().specificmethod() # Use DB specific method

Check the docs here: https://mypy.readthedocs.io/en/stable/generics.html#generic-methods-and-generic-self

A potential problem could be with the used local("db") initialization, but I think that could be solved with some refactoring.

@ankush
Copy link
Member Author

ankush commented Apr 14, 2021

Thanks, @kisg for your suggestion.

from frappe.database import Database
db : Database = local("db")

We do not want to import Database in frappe. frappe module gets imported in many places it's an unnecessary import cost. (especially in CLI)

As of now, there are only two databases officially supported in frappe and only one in erpnext. So technically Union[MariaDB, PostgresDB] is the same as Database as union is supposed to force check for implementation with both classes. Correct me if I am wrong here.

Also at present we are not type checking at all, I am working on adding types to some of the core functionality gradually. Only after that, the second part of your suggestion would apply.

A potential problem could be with the used local("db") initialization, but I think that could be solved with some refactoring.

Open for suggestions 😄

@kisg
Copy link

kisg commented Apr 14, 2021

Thanks, @kisg for your suggestion.

from frappe.database import Database
db : Database = local("db")

We do not want to import Database in frappe. frappe module gets imported in many places it's an unnecessary import cost. (especially in CLI)

The Database will be imported when you call get_db() anyways, so I am not sure if there is anything gained here. Do you have any benchmarks for this?
However, an even better solution could be to define an abstract IDatabase interface class (with proper static type annotations), and import only that.

As of now, there are only two databases officially supported in frappe and only one in erpnext. So technically Union[MariaDB, PostgresDB] is the same as Database as union is supposed to force check for implementation with both classes. Correct me if I am wrong here.

I am hoping to add support for PostgreSQL in ERPNext, at least I already started looking at it.

My concern is that you are adding code that introduces a dependency on the exact DB types instead of decoupling it.

Also: Union[A, B] means that either A or B type is allowed, which is not the same as using the supertype of A and B. The Union[] version allows any custom API defined in either A or B, in our case any MariaDB or Postgres specific function can be used anywhere and the type checker will be perfectly fine with it. But it will break at runtime.

The purpose of static type checking should be to catch these kind of errors, in my opinion.

Also at present we are not type checking at all, I am working on adding types to some of the core functionality gradually. Only after that, the second part of your suggestion would apply.

A potential problem could be with the used local("db") initialization, but I think that could be solved with some refactoring.

Open for suggestions 😄

Let's agree on the above points first. :)

@ankush
Copy link
Member Author

ankush commented Apr 15, 2021

Also: Union[A, B] means that either A or B type is allowed, which is not the same as using the supertype of A and B. The Union[] version allows any custom API defined in either A or B, in our case any MariaDB or Postgres specific function can be used anywhere and the type checker will be perfectly fine with it. But it will break at runtime.

Not true.

Ref: https://mypy.readthedocs.io/en/stable/kinds_of_types.html#union-types

Operations are valid for union types only if they are valid for every union item.

Try this toy example:

import typing
from typing import Union


class MariaDB():
	def mariadb_method(self):
		pass

	def common(self):
		pass


class Postgres():
	def postgres_method(self):
		pass

	def common(self):
		pass


db: Union[MariaDB, Postgres] = MariaDB()

db.mariadb_method()
db.postgres_method()
db.common()

mypy output:

test.py:23: error: Item "Postgres" of "Union[MariaDB, Postgres]" has no attribute "mariadb_method"
test.py:24: error: Item "MariaDB" of "Union[MariaDB, Postgres]" has no attribute "postgres_method"
Found 2 errors in 1 file (checked 1 source file)

@kisg
Copy link

kisg commented Apr 15, 2021

Also: Union[A, B] means that either A or B type is allowed, which is not the same as using the supertype of A and B. The Union[] version allows any custom API defined in either A or B, in our case any MariaDB or Postgres specific function can be used anywhere and the type checker will be perfectly fine with it. But it will break at runtime.

Not true.

Ref: https://mypy.readthedocs.io/en/stable/kinds_of_types.html#union-types

You are right, I should have double checked in mypy (I only checked in PEP-484, where this is not explicit). Sorry for the confusion.

Still, the fact that both MariaDB and Postgres implementation classes have the same attribute does not mean that they are actually meant to be used by a client directly in DB independent code. I think it is a cleaner approach to define the DB interface explicitly, and using that in static typing.

@netchampfaris
Copy link
Contributor

@kisg I wouldn't say Frappe Framework is fully DB independent yet. Even the current Postgres support is not fully functional in some actions like Rename.

But this change improves the DX right now for all developers working on Frappe. So, until we have better support for multiple databases, we can merge this.

@mergify mergify bot merged commit 960fcc2 into frappe:develop Apr 16, 2021
@ankush ankush deleted the db_statica_hack branch April 19, 2021 05:52
@ankush ankush mentioned this pull request Apr 29, 2021
5 tasks
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Nov 30, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants