visualize_data_model.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. #!/usr/bin/env python
  2. import argparse
  3. import sys
  4. from getpass import getpass
  5. import inspect
  6. from importlib import import_module
  7. import pkg_resources
  8. from sqlalchemy import MetaData
  9. from sqlalchemy.orm import class_mapper
  10. """
  11. This is our dev script to make images displaying our data model.
  12. At the moment, this code requires an unreleased version of sqlalchemy_schemadisplay, install it like this:
  13. pip install git+https://github.com/fschulze/sqlalchemy_schemadisplay.git@master
  14. See also https://github.com/fschulze/sqlalchemy_schemadisplay/issues/21
  15. For rendering of graphs (instead of saving a PNG), you'll need pillow:
  16. pip install pillow
  17. """
  18. DEBUG = True
  19. # List here modules which should be scanned for the UML version
  20. RELEVANT_MODULES = [
  21. "task_runs",
  22. "data_sources",
  23. "generic_assets",
  24. "user",
  25. "time_series",
  26. ]
  27. # List here tables in the data model which are currently relevant
  28. RELEVANT_TABLES = [
  29. "role",
  30. "account",
  31. "account_role",
  32. "fm_user",
  33. "data_source",
  34. "latest_task_run",
  35. "generic_asset_type",
  36. "generic_asset",
  37. "sensor",
  38. "timed_belief",
  39. "timed_value",
  40. ]
  41. # The following two lists are useful for transition periods, when some tables are legacy, and some have been added.
  42. # This allows you to show the old model as well as the future model.
  43. LEGACY_TABLES = []
  44. RELEVANT_TABLES_NEW = []
  45. def check_sqlalchemy_schemadisplay_installation():
  46. """Make sure the library which translates the model into a graph structure
  47. is installed with the right version."""
  48. try:
  49. import sqlalchemy_schemadisplay # noqa: F401
  50. except ImportError:
  51. print(
  52. "You need to install sqlalchemy_schemadisplay==1.4dev0 or higher.\n"
  53. "Try this: pip install git+https://github.com/fschulze/sqlalchemy_schemadisplay.git@master"
  54. )
  55. sys.exit(0)
  56. packages_versions = {p.project_name: p.version for p in pkg_resources.working_set}
  57. if packages_versions["sqlalchemy-schemadisplay"] < "1.4":
  58. print(
  59. "Your version of sqlalchemy_schemadisplay is too small. Should be 1.4 or higher."
  60. " Currently, only 1.4dev0 is available with needed features.\n"
  61. "Try this: pip install git+https://github.com/fschulze/sqlalchemy_schemadisplay.git@master"
  62. )
  63. sys.exit(0)
  64. def uses_dot(func):
  65. """
  66. Decorator to make sure that if dot/graphviz (for drawing the graph)
  67. is not installed there is a proper message.
  68. """
  69. def wrapper(*args, **kwargs):
  70. try:
  71. return func(*args, **kwargs)
  72. except FileNotFoundError as fnfe:
  73. if '"dot" not found in path' in str(fnfe):
  74. print(fnfe)
  75. print("Try this (on debian-based Linux): sudo apt install graphviz")
  76. sys.exit(2)
  77. else:
  78. raise
  79. return wrapper
  80. @uses_dot
  81. def create_schema_pic(
  82. pg_url, pg_user, pg_pwd, store: bool = False, deprecated: bool = False
  83. ):
  84. """Create a picture of the SCHEMA of relevant tables."""
  85. print("CREATING SCHEMA PICTURE ...")
  86. print(
  87. f"Connecting to database {pg_url} as user {pg_user} and loading schema metadata ..."
  88. )
  89. db_metadata = MetaData(f"postgresql://{pg_user}:{pg_pwd}@{pg_url}")
  90. relevant_tables = RELEVANT_TABLES
  91. if deprecated:
  92. relevant_tables += LEGACY_TABLES
  93. else:
  94. relevant_tables += RELEVANT_TABLES_NEW
  95. kwargs = dict(
  96. metadata=db_metadata,
  97. show_datatypes=False, # The image would get nasty big if we'd show the datatypes
  98. show_indexes=False, # ditto for indexes
  99. rankdir="LR", # From left to right (instead of top to bottom)
  100. concentrate=False, # Don't try to join the relation lines together
  101. restrict_tables=relevant_tables,
  102. )
  103. print("Creating the pydot graph object...")
  104. if DEBUG:
  105. print(f"Relevant tables: {relevant_tables}")
  106. graph = create_schema_graph(**kwargs)
  107. if store:
  108. print("Storing as image (db_schema.png) ...")
  109. graph.write_png("db_schema.png") # write out the file
  110. else:
  111. show_image(graph)
  112. @uses_dot
  113. def create_uml_pic(store: bool = False, deprecated: bool = False):
  114. print("CREATING UML CODE DIAGRAM ...")
  115. print("Finding all the relevant mappers in our model...")
  116. mappers = []
  117. # map comparable names to model classes. We compare without "_" and in lowercase.
  118. # Note: This relies on model classes and their tables having the same name,
  119. # ignoring capitalization and underscores.
  120. relevant_models = {}
  121. for module in RELEVANT_MODULES:
  122. relevant_models.update(
  123. {
  124. mname.lower(): mclass
  125. for mname, mclass in inspect.getmembers(
  126. import_module(f"flexmeasures.data.models.{module}")
  127. )
  128. if inspect.isclass(mclass) and issubclass(mclass, flexmeasures_db.Model)
  129. }
  130. )
  131. relevant_tables = RELEVANT_TABLES
  132. if deprecated:
  133. relevant_tables += LEGACY_TABLES
  134. else:
  135. relevant_tables += RELEVANT_TABLES_NEW
  136. if DEBUG:
  137. print(f"Relevant tables: {relevant_tables}")
  138. print(f"Relevant models: {relevant_models}")
  139. matched_models = {
  140. m: c for (m, c) in relevant_models.items() if c.__tablename__ in relevant_tables
  141. }
  142. for model_name, model_class in matched_models.items():
  143. if DEBUG:
  144. print(f"Loading class {model_class.__name__} ...")
  145. mappers.append(class_mapper(model_class))
  146. print("Creating diagram ...")
  147. kwargs = dict(
  148. show_operations=False, # not necessary in this case
  149. show_multiplicity_one=False, # some people like to see the ones, some don't
  150. )
  151. print("Creating the pydot graph object...")
  152. graph = create_uml_graph(mappers, **kwargs)
  153. if store:
  154. print("Storing as image (uml_diagram.png) ...")
  155. graph.write_png("uml_diagram.png") # write out the file
  156. else:
  157. show_image(graph)
  158. @uses_dot
  159. def show_image(graph):
  160. """
  161. Show an image created through sqlalchemy_schemadisplay.
  162. We could also have used functions in there, but:
  163. https://github.com/fschulze/sqlalchemy_schemadisplay/pull/14
  164. Anyways, this is a good place to check for PIL and those two functions
  165. were containing almost identical logic - these two lines here are
  166. an improvement.
  167. """
  168. from io import BytesIO
  169. try:
  170. from PIL import Image
  171. except ImportError:
  172. print("Please pip-install the pillow library in order to show graphs.")
  173. sys.exit(0)
  174. print("Creating PNG stream ...")
  175. iostream = BytesIO(graph.create_png())
  176. print("Showing image ...")
  177. Image.open(iostream).show()
  178. if __name__ == "__main__":
  179. if len(sys.argv) == 1:
  180. sys.argv.append("--help")
  181. if DEBUG:
  182. print("DEBUG is on")
  183. check_sqlalchemy_schemadisplay_installation()
  184. from sqlalchemy_schemadisplay import create_schema_graph, create_uml_graph
  185. parser = argparse.ArgumentParser(
  186. description="Visualize our data model. Creates image files."
  187. )
  188. parser.add_argument(
  189. "--schema", action="store_true", help="Visualize the data model schema."
  190. )
  191. parser.add_argument(
  192. "--uml",
  193. action="store_true",
  194. help="Visualize the relationships available in code (UML style).",
  195. )
  196. parser.add_argument(
  197. "--deprecated",
  198. action="store_true",
  199. help="If given, include the parts of the depcrecated data model, and leave out their new counterparts.",
  200. )
  201. parser.add_argument(
  202. "--store",
  203. action="store_true",
  204. help="Store the images as files, instead of showing them directly (which requires pillow).",
  205. )
  206. parser.add_argument(
  207. "--pg_url",
  208. help="Postgres URL (needed if --schema is on).",
  209. default="localhost:5432/flexmeasures",
  210. )
  211. parser.add_argument(
  212. "--pg_user",
  213. help="Postgres user (needed if --schema is on).",
  214. default="flexmeasures",
  215. )
  216. args = parser.parse_args()
  217. if args.schema:
  218. pg_pwd = getpass(f"Please input the postgres password for user {args.pg_user}:")
  219. create_schema_pic(
  220. args.pg_url,
  221. args.pg_user,
  222. pg_pwd,
  223. store=args.store,
  224. deprecated=args.deprecated,
  225. )
  226. elif args.uml:
  227. try:
  228. from flexmeasures.data import db as flexmeasures_db
  229. except ImportError as ie:
  230. print(
  231. f"We need flexmeasures.data to be in the path, so we can read the data model. Error: '{ie}''."
  232. )
  233. sys.exit(0)
  234. create_uml_pic(store=args.store, deprecated=args.deprecated)
  235. else:
  236. print("Please specify either --uml or --schema. What do you want to see?")