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

[Issue 1325]: add python interactive console #1331

Merged
merged 18 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -250,5 +250,8 @@ copy-oracle-data:
login: start ## Start shell in running container
docker exec -it $(APP_NAME) bash

console: ## Start interactive Python console
$(PY_RUN_CMD) python3 -i -m src.tool.console.interactive

help: ## Prints the help documentation and info about each command
@grep -E '^[/a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
71 changes: 71 additions & 0 deletions api/src/db/models/opportunity_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ def applicant_types(self) -> list[ApplicantType]:
# Helper method for serialization of the API response
return [a.applicant_type for a in self.link_applicant_types]

def __repr__(self):
return (f"<Opportunity(opportunity_id={self.opportunity_id}, "
f"opportunity_number='{self.opportunity_number}', "
f"opportunity_title='{self.opportunity_title}', "
f"agency='{self.agency}', category='{self.category}', ")
Copy link
Contributor Author

@rylew1 rylew1 Feb 27, 2024

Choose a reason for hiding this comment

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

@chouinar - hope its ok to add repr methods on the db models so printing is a little bit cleaner - where before it was just printing the object and mem address (I was having call a vars(object) on it to see the values). Maybe there's a better way though than manually spelling it out for each model though.

image


class OpportunitySummary(Base, TimestampMixin):
__tablename__ = "opportunity_summary"
Expand Down Expand Up @@ -133,6 +138,49 @@ class OpportunitySummary(Base, TimestampMixin):
updated_by: Mapped[str | None]
created_by: Mapped[str | None]

def __repr__(self):
def format_attr(value):
"""Format the attribute to display 'None' or slice if it's a string."""
if value is None:
return 'None'
elif isinstance(value, str):
return f"'{value[:25]}...'" if len(value) > 25 else f"'{value}'"
else:
return value

return (f"<OpportunitySummary("
f"opportunity_id={format_attr(self.opportunity_id)}, "
f"opportunity_status='{format_attr(self.opportunity_status)}', "
f"summary_description={format_attr(self.summary_description)}, "
f"is_cost_sharing={format_attr(self.is_cost_sharing)}, "
f"close_date={format_attr(self.close_date)}, "
f"close_date_description={format_attr(self.close_date_description)}, "
f"post_date={format_attr(self.post_date)}, "
f"archive_date={format_attr(self.archive_date)}, "
f"unarchive_date={format_attr(self.unarchive_date)}, "
f"expected_number_of_awards={format_attr(self.expected_number_of_awards)}, "
f"estimated_total_program_funding={format_attr(self.estimated_total_program_funding)}, "
f"award_floor={format_attr(self.award_floor)}, "
f"award_ceiling={format_attr(self.award_ceiling)}, "
f"additional_info_url={format_attr(self.additional_info_url)}, "
f"additional_info_url_description={format_attr(self.additional_info_url_description)}, "
f"version_number={format_attr(self.version_number)}, "
f"modification_comments={format_attr(self.modification_comments)}, "
f"funding_category_description={format_attr(self.funding_category_description)}, "
f"applicant_eligibility_description={format_attr(self.applicant_eligibility_description)}, "
f"agency_code={format_attr(self.agency_code)}, "
f"agency_name={format_attr(self.agency_name)}, "
f"agency_phone_number={format_attr(self.agency_phone_number)}, "
f"agency_contact_description={format_attr(self.agency_contact_description)}, "
f"agency_email_address={format_attr(self.agency_email_address)}, "
f"agency_email_address_description={format_attr(self.agency_email_address_description)}, "
f"can_send_mail={format_attr(self.can_send_mail)}, "
f"publisher_profile_id={format_attr(self.publisher_profile_id)}, "
f"publisher_user_id={format_attr(self.publisher_user_id)}, "
f"updated_by={format_attr(self.updated_by)}, "
f"created_by={format_attr(self.created_by)})"
f">")


class OpportunityAssistanceListing(Base, TimestampMixin):
__tablename__ = "opportunity_assistance_listing"
Expand All @@ -149,6 +197,14 @@ class OpportunityAssistanceListing(Base, TimestampMixin):
updated_by: Mapped[str | None]
created_by: Mapped[str | None]

def __repr__(self):
return (f"<OpportunityAssistanceListing("
f"opportunity_assistance_listing_id={self.opportunity_assistance_listing_id}, "
f"opportunity_id={self.opportunity_id}, "
f"assistance_listing_number='{self.assistance_listing_number}', "
f"program_title='{self.program_title}', "
f"updated_by='{self.updated_by}', "
f"created_by='{self.created_by}')>")

class LinkFundingInstrumentOpportunity(Base, TimestampMixin):
__tablename__ = "link_funding_instrument_opportunity"
Expand All @@ -168,6 +224,12 @@ class LinkFundingInstrumentOpportunity(Base, TimestampMixin):
updated_by: Mapped[str | None]
created_by: Mapped[str | None]

def __repr__(self):
return (f"<LinkFundingInstrumentOpportunity(opportunity_id={self.opportunity_id}, "
f"funding_instrument='{self.funding_instrument}', "
f"updated_by='{self.updated_by}', created_by='{self.created_by}')>")



class LinkFundingCategoryOpportunity(Base, TimestampMixin):
__tablename__ = "link_funding_category_opportunity"
Expand All @@ -187,6 +249,10 @@ class LinkFundingCategoryOpportunity(Base, TimestampMixin):
updated_by: Mapped[str | None]
created_by: Mapped[str | None]

def __repr__(self):
return (f"<LinkFundingCategoryOpportunity(opportunity_id={self.opportunity_id}, "
f"funding_category='{self.funding_category}', "
f"updated_by='{self.updated_by}', created_by='{self.created_by}')>")

class LinkApplicantTypeOpportunity(Base, TimestampMixin):
__tablename__ = "link_applicant_type_opportunity"
Expand All @@ -205,3 +271,8 @@ class LinkApplicantTypeOpportunity(Base, TimestampMixin):

updated_by: Mapped[str | None]
created_by: Mapped[str | None]

def __repr__(self):
return (f"<LinkApplicantTypeOpportunity(opportunity_id={self.opportunity_id}, "
f"applicant_type='{self.applicant_type}', "
f"updated_by='{self.updated_by}', created_by='{self.created_by}')>")
26 changes: 26 additions & 0 deletions api/src/db/models/transfer/topportunity_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,29 @@ class TransferTopportunity(Base, TimestampMixin):
last_upd_date: Mapped[date | None]
creator_id: Mapped[str | None] = mapped_column(VARCHAR(length=200))
created_date: Mapped[date | None]

def __repr__(self):
def safe_repr(attr):
"""Safely format the attribute for __repr__, handling None values."""
if attr is None:
return 'None'
elif isinstance(attr, str):
return f"'{attr[:50]}...'" if len(attr) > 50 else f"'{attr}'"
else:
return str(attr)

return (f"<TransferTopportunity(opportunity_id={safe_repr(self.opportunity_id)}, "
f"oppnumber={safe_repr(self.oppnumber)}, "
f"opptitle={safe_repr(self.opptitle)}, "
f"owningagency={safe_repr(self.owningagency)}, "
f"oppcategory={safe_repr(self.oppcategory)}, "
f"category_explanation={safe_repr(self.category_explanation)}, "
f"is_draft={safe_repr(self.is_draft)}, "
f"revision_number={safe_repr(self.revision_number)}, "
f"modified_comments={safe_repr(self.modified_comments)}, "
f"publisheruid={safe_repr(self.publisheruid)}, "
f"publisher_profile_id={safe_repr(self.publisher_profile_id)}, "
f"last_upd_id={safe_repr(self.last_upd_id)}, "
f"last_upd_date={safe_repr(self.last_upd_date)}, "
f"creator_id={safe_repr(self.creator_id)}, "
f"created_date={safe_repr(self.created_date)})>")
Empty file added api/src/tool/__init__.py
Empty file.
Empty file.
132 changes: 132 additions & 0 deletions api/src/tool/console/interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#
# Interactive console for using database and services.
#
# Connects to the database then drops into a Python interactive prompt.
#
# Use via the `-i` flag to python, for example:
# poetry run python3 -i -m src.tool.console.interactive
#

from types import ModuleType

import rich
import rich.panel
import rich.pretty

import src.adapters.db as db
import src.db
import src.db.models
import src.logging
import src.util
import tests.src.db.models.factories
from src.adapters.db.clients.postgres_client import PostgresDBClient
from src.adapters.db.clients.postgres_config import get_db_config

INTRO = """
Simpler Grants Gov Python console

Useful things:

│ db = database session
│ f = DB factories module
│ u = utilities module
│ r = function to reload REPL

Tips:
★ Tab-completion is available
★ History is available (use ↑↓ keys)
★ Use Ctrl+D to exit
"""


def interactive_console() -> dict:
"""Set up variables and print a introduction message for the interactive console."""

db_session = connect_to_database()

print(INTRO.format(**locals()))

variables = dict()
variables.update(vars(src.db.models.opportunity_models))
variables.update(vars(src.db.models.lookup_models))

# This goes after the imports of entire modules, so the console reserved
# names (db, fineos, etc) take precedence. This might break some modules
# that expect something different under those names.
variables.update(locals())

# DB
variables["db_session"] = db_session
variables["dbs"] = db_session

# DB Factories
factories_module = tests.src.db.models.factories
if isinstance(db_session, db.Session):
factories_module._db_session = db_session
variables["f"] = tests.src.db.models.factories
Copy link
Collaborator

Choose a reason for hiding this comment

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

FYI - we might want to adjust where this utility lives - I made it so the factories are only a test component with the dependencies as dev only. We shouldn't be importing them into the src path ideally. This code won't ever run non-locally, but if you tried to do so, it would complain about missing packages.

I'm not sure if the tests folder is quite the right place, but I did put a utility script in tests/lib/seed_local_db.py - might be worth coming up with a happier approach.

Copy link
Contributor Author

@rylew1 rylew1 Feb 27, 2024

Choose a reason for hiding this comment

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

I'm open but would propose splitting that piece out to a separate PR.

I do think it would make sense to close the loop on the intercepting session errors though - so that we don't have to manually do a rollback when it gets to an error state

Copy link
Collaborator

Choose a reason for hiding this comment

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

I looked into the rollback automation, not finding a ton of approaches. Tried rolling back automatically when an error happens and that just cascades into other errors. In short, SQLAlchemy changed sessions to generally be made for handling a single set of transactions (great for an API, less so for an interactive console).

Without thinking through it much, the right approach probably involves lambdas/sessionmakers to automatically make new sessions when old ones error, but that's likely something that will take a while to figure out

Copy link
Contributor Author

@rylew1 rylew1 Feb 27, 2024

Choose a reason for hiding this comment

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

Want to just approve and merge then after fixing the repr stuff? We've got db queries and ability to play with factories - that's a win I think.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, we can put that somewhere in the backlog, just adjust the repr bit and we can go from there


# Easy access to utilities
variables["u"] = src.util
variables["util"] = src.util

# Easy reloading of modules imported in REPL, for retrying something after a
# code change without dropping out of REPL
variables["r"] = reload_repl
variables["reload"] = reload_repl
variables["reload_module"] = reload_module

return variables


def connect_to_database() -> db.Session | Exception:
db_config = get_db_config()

# errors sometimes dump sensitive info
# (since we're doing locally, we don't need to hide)
db_config.hide_sql_parameter_logs = False
db_session: db.Session | Exception
try:
db_session = PostgresDBClient(db_config).get_session()
except Exception as err:
db_session = err

return db_session


def reload_repl() -> None:
import importlib
from sys import modules

for module in set(modules.values()):
# only reload our code
if "<module 'src." not in str(module):
continue

# individual database model modules can be particular in how they are
# loaded, so don't automatically reload them
if "<module 'src.db.models." in str(module):
continue

# reloading the logging initialization and stuff can cause some issues,
# avoid it all for now
if "<module 'src.util.logging" in str(module):
continue

try:
importlib.reload(module)
except: # noqa: B001
# there are some problems that are swept under the rug here
pass


def reload_module(m: ModuleType) -> None:
import importlib

importlib.reload(m)


if __name__ == "__main__":
with src.logging.init(__package__):
interactive_variables = interactive_console()
globals().update(interactive_variables)
rich.pretty.install(indent_guides=True, max_length=20, max_string=400)
4 changes: 4 additions & 0 deletions documentation/api/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ A very simple [docker-compose.yml](../../docker-compose.yml) has been included t

See the [Makefile](../../api/Makefile) for a full list of commands you can run.

The `make console` command initializes a Python REPL environment pre-configured with database connectivity. This allows developers to perform database queries, utilize factories for data generation, and interact with the application's models directly.
- Writing a query: `dbs.query(Opportunity).all()`
- Saving some factory generated data to the db: `f.OpportunityFactory.create()`

## Docker and Native Development

Several components like tests, linting, and scripts can be run either inside of the Docker container, or outside on your native machine.
Expand Down
Loading