piecash.core.session — Python interface to GnuCash documents 0.15.0 documentation

# Source code for piecash.core.session

    import datetime
    import os
    import shutil
    import socket
    from collections import defaultdict

    from sqlalchemy import event, Column, VARCHAR, INTEGER, Table, PrimaryKeyConstraint
    from sqlalchemy.sql.ddl import DropConstraint, DropIndex
    from sqlalchemy_utils import database_exists

    from .book import Book
    from .._common import GnucashException
    from ..sa_extra import create_piecash_engine, DeclarativeBase, Session

    version_supported = {
        '2.6': {
            'Gnucash': 2060400,
            'Gnucash-Resave': 19920,
            'accounts': 1,
            'billterms': 2,
            'books': 1,
            'budget_amounts': 1,
            'budgets': 1,
            'commodities': 1,
            'customers': 2,
            'employees': 2,
            'entries': 3,
            'invoices': 3,
            'jobs': 1,
            'lots': 2,
            'orders': 1,
            'prices': 2,
            'recurrences': 2,
            'schedxactions': 1,
            'slots': 3,
            'splits': 4,
            'taxtable_entries': 3,
            'taxtables': 2,
            'transactions': 3,
            'vendors': 1,
        },
        '2.7': {
            'Gnucash': 2070200,
            'Gnucash-Resave': 19920,
            'accounts': 1,
            'billterms': 2,
            'books': 1,
            'budget_amounts': 1,
            'budgets': 1,
            'commodities': 1,
            'customers': 2,
            'employees': 2,
            'entries': 4,
            'invoices': 4,
            'jobs': 1,
            'lots': 2,
            'orders': 1,
            'prices': 3,
            'recurrences': 2,
            'schedxactions': 1,
            'slots': 4,
            'splits': 4,
            'taxtable_entries': 3,
            'taxtables': 2,
            'transactions': 4,
            'vendors': 1,
        }
    }

    # this is not a declarative as it is used before binding the session to an engine.
    gnclock = Table(u'gnclock', DeclarativeBase.metadata,
                    Column('hostname', VARCHAR(length=255)),
                    Column('pid', INTEGER()),
                    )

    class Version(DeclarativeBase):
        """The declarative class for the 'versions' table.
        """
        __tablename__ = 'versions'

        __table_args__ = {}

        # column definitions
        # : The name of the table
        table_name = Column('table_name', VARCHAR(length=50), primary_key=True, nullable=False)
        #: The version for the table
        table_version = Column('table_version', INTEGER(), nullable=False)

        def __init__(self, table_name, table_version):
            self.table_name = table_name
            self.table_version = table_version

        def __unirepr__(self):
            return u"Version<{}={}>".format(self.table_name, self.table_version)

    def build_uri(sqlite_file=None,
                  uri_conn=None,
                  db_type=None,
                  db_user=None,
                  db_password=None,
                  db_name=None,
                  db_host=None,
                  db_port=None,
                  check_same_thread=True,
                  ):
        """Create the connection string in function of some choices.

        :param str sqlite_file: a path to an sqlite3 file (only used if uri_conn is None)
        :param str uri_conn: a sqlalchemy connection string
        :param str db_type: type of database in ["postgres","mysql"]
        :param str db_user: username of database
        :param str db_password: password for the use of database
        :param str db_name: name of database
        :param str db_host: host of database
        :param str db_port: port of database
        :param bool check_same_thread: sqlite flag that restricts connection use to the thread that created (see False for use in ipython/flask/... but read first https://docs.python.org/3/library/sqlite3.html)

        :return: the connection string
        :rtype: str
        """
        db_config = (db_type, db_host, db_port, db_name, db_user, db_password)
        db_config_isdefined = map(lambda x: x is not None, db_config[:-1])
        if any(db_config_isdefined):
            if not all(db_config_isdefined):
                raise ValueError("When using db_* arguments, all must be specified : {}".format(db_config))

            uri_conn = {"postgres": "postgresql://{username}:{password}@{host}:{port}/{name}",
                        "mysql": "mysql+pymysql://{username}:{password}@{host}:{port}/{name}?charset=utf8",
                        }[db_type].format(username=db_user, password=db_password,
                                          host=db_host, port=db_port,
                                          name=db_name)

        # db_postgres_uri = "postgresql://postgres:{pwd}@localhost:5432/foo".format(pwd=pg_password)
        # db_mysql_uri = "mysql+pymysql://travis:@localhost/foo?charset=utf8"
        # db_sqlite_uri = "sqlite:///{}".format(db_sqlite)

        if sqlite_file and uri_conn:
            raise ValueError("Only one of 'sqlite_file' or 'uri_conn' argument can be defined")

        if uri_conn is None:
            # fallback on sqlite
            if sqlite_file:
                if sqlite_file.startswith("sqlite:///"):
                    # already have the protocol specified.
                    uri_conn = sqlite_file
                else:
                    uri_conn = "sqlite:///{}".format(sqlite_file)
            else:
                uri_conn = "sqlite:///:memory:"
            if check_same_thread is False:
                uri_conn = uri_conn + "?check_same_thread=False"

        return uri_conn

    def create_book(sqlite_file=None,
                    uri_conn=None,
                    currency="EUR",
                    overwrite=False,
                    keep_foreign_keys=False,
                    db_type=None,
                    db_user=None,
                    db_password=None,
                    db_name=None,
                    db_host=None,
                    db_port=None,
                    check_same_thread=True,
                    version_format="2.6",
                    **kwargs):
        """Create a new empty GnuCash book. If both sqlite_file and uri_conn are None, then an "in memory" sqlite book is created.

        :param str sqlite_file: a path to an sqlite3 file (only used if uri_conn is None)
        :param str uri_conn: a sqlalchemy connection string
        :param str currency: the ISO symbol of the default currency of the book
        :param bool overwrite: True if book should be deleted and recreated if it exists already
        :param bool keep_foreign_keys: True if the foreign keys should be kept (may not work at all with GnuCash)
        :param str db_type: type of database in ["postgres","mysql"]
        :param str db_user: username of database
        :param str db_password: password for the use of database
        :param str db_name: name of database
        :param str db_host: host of database
        :param str db_port: port of database
        :param bool check_same_thread: sqlite flag that restricts connection use to the thread that created (see False for use in ipython/flask/... but read first https://docs.python.org/3/library/sqlite3.html)
        :param str version_format: the format (2.6 or 2.7) for the schema tables to generate

        :return: the document as a gnucash session
        :rtype: :class:`GncSession`

        :raises GnucashException: if document already exists and overwrite is False
        """
        from sqlalchemy_utils.functions import database_exists, create_database, drop_database

        uri_conn = build_uri(sqlite_file, uri_conn,
                             db_type, db_user, db_password, db_name, db_host, db_port,
                             check_same_thread,
                             )

        # create database (if DB is not a sqlite in memory)
        if uri_conn != "sqlite:///:memory:":
            if database_exists(uri_conn):
                if overwrite:
                    drop_database(uri_conn)
                else:
                    raise GnucashException("'{}' db already exists".format(uri_conn))
            create_database(uri_conn)

        engine = create_piecash_engine(uri_conn, **kwargs)

        # drop constraints if we de not want to keep them (keep_foreign_keys=False), the default
        if not keep_foreign_keys:
            for n, tbl in DeclarativeBase.metadata.tables.items():
                # drop index constraints
                for idx in tbl.indexes:
                    event.listen(tbl,
                                 "after_create",
                                 DropIndex(idx),
                                 once=True)
                # drop FK constraints
                for cstr in tbl.constraints:
                    if isinstance(cstr, PrimaryKeyConstraint):
                        continue
                    else:
                        event.listen(tbl,
                                     "before_drop",
                                     DropConstraint(cstr),
                                     once=True)
        #
        # create all (tables, fk, ...)
        DeclarativeBase.metadata.create_all(engine)

        s = Session(bind=engine)

        # create all rows in version table
        assert version_format in version_supported, "The 'version_format'={} is not supported. " \
                                                    "Choose one of {}".format(version_format,
                                                                              list(version_supported.keys()))
        for table_name, table_version in version_supported[version_format].items():
            s.add(Version(table_name=table_name, table_version=table_version))

        # create book and merge with session

        b = Book()
        s.add(b)
        adapt_session(s, book=b, readonly=False)

        # create commodities and initial accounts
        from .account import Account

        b.root_account = Account(name="Root Account", type="ROOT", commodity=None, book=b)
        b.root_template = Account(name="Template Root", type="ROOT", commodity=None, book=b)
        b["default-currency"] = b.currencies(mnemonic=currency)
        b.save()

        return b

    def open_book(sqlite_file=None,
                  uri_conn=None,
                  readonly=True,
                  open_if_lock=False,
                  do_backup=True,
                  db_type=None,
                  db_user=None,
                  db_password=None,
                  db_name=None,
                  db_host=None,
                  db_port=None,
                  check_same_thread=True,
                  **kwargs):
        """Open an existing GnuCash book

        :param str sqlite_file: a path to an sqlite3 file (only used if uri_conn is None)
        :param str uri_conn: a sqlalchemy connection string
        :param bool readonly: open the file as readonly (useful to play with and avoid any unwanted save)
        :param bool open_if_lock: open the file even if it is locked by another user
            (using open_if_lock=True with readonly=False is not recommended)
        :param bool do_backup: do a backup if the file written in RW (i.e. readonly=False)
            (this only works with the sqlite backend and copy the file with .{:%Y%m%d%H%M%S}.gnucash appended to it)
        :param str db_type: type of database in ["postgres","mysql"]
        :param str db_user: username of database
        :param str db_password: password for the use of database
        :param str db_name: name of database
        :param str db_host: host of database
        :param str db_port: port of database
        :param bool check_same_thread: sqlite flag that restricts connection use to the thread that created (see False for use in ipython/flask/... but read first https://docs.python.org/3/library/sqlite3.html)

        :return: the document as a gnucash session
        :rtype: :class:`GncSession`
        :raises GnucashException: if the document does not exist
        :raises GnucashException: if there is a lock on the file and open_if_lock is False

        """
        uri_conn = build_uri(sqlite_file, uri_conn,
                             db_type, db_user, db_password, db_name, db_host, db_port,
                             check_same_thread,
                             )

        if uri_conn == "sqlite:///:memory:":
            raise ValueError("An in memory sqlite gnucash databook cannot be opened, it should be created")

        # create database (if not sqlite in memory)
        if not database_exists(uri_conn):
            raise GnucashException("Database '{}' does not exist (please use create_book to create " \
                                   "GnuCash books from scratch)".format(uri_conn))

        engine = create_piecash_engine(uri_conn, **kwargs)

        # backup database if readonly=False and do_backup=True
        if not readonly and do_backup:
            if engine.name != "sqlite":
                raise GnucashException(
                    "Cannot do a backup for engine '{}'. Do yourself a backup and then specify do_backup=False".format(
                        engine.name))

            url = uri_conn[len("sqlite:///"):].replace("?check_same_thread=False", "")
            url_backup = url + ".{:%Y%m%d%H%M%S}.gnucash".format(datetime.datetime.now())

            shutil.copyfile(url, url_backup)

        locks = list(engine.execute(gnclock.select()))

        # ensure the file is not locked by GnuCash itself
        if locks and not open_if_lock:
            raise GnucashException("Lock on the file")

        s = Session(bind=engine)

        # check the versions in the table versions is consistent with the API
        version_book = {v.table_name: v.table_version
                        for v in s.query(Version).all()
                        if "Gnucash" not in v.table_name}
        assert any(version_book == {k: v
                                    for k, v in vt.items() if
                                    "Gnucash" not in k}
                   for version, vt in version_supported.items()), "Unsupported table versions"

        book = s.query(Book).one()
        adapt_session(s, book=book, readonly=readonly)

        return book

    def adapt_session(session, book, readonly):
        """
        Change the SA session object to add some features.

        :param session: the SA session object that will be modified in place
        :param book: the gnucash singleton book linked to the SA session
        :param readonly: True if the session should not allow commits.
        :return:
        """
        # link session and book together
        book.session = session
        session.book = book
        book.uri = session.bind.url

        # def new_flush(*args, **kwargs):
        # if session.dirty or session.new or session.deleted:
        # session.rollback()
        # raise GnucashException("You cannot change the DB, it is locked !")

        # add logic to make session readonly
        def readonly_commit(*args, **kwargs):
            # session.rollback()
            raise GnucashException("You cannot change the DB, it was opened as readonly!")

        if readonly:
            session.commit = readonly_commit

        # add logic to create/delete GnuCash locks
        def delete_lock():
            session.execute(gnclock.delete(whereclause=(gnclock.c.hostname == socket.gethostname())
                                                       and (gnclock.c.pid == os.getpid())))
            session.commit()

        session.delete_lock = delete_lock

        def create_lock():
            session.execute(gnclock.insert(values=dict(hostname=socket.gethostname(), pid=os.getpid())))
            session.commit()

        session.create_lock = create_lock

        # add logic to track if a session has been modified or not
        session._is_modified = False
        session._all_changes = {}

        @event.listens_for(session, 'after_flush')
        def receive_after_flush(session, flush_context):
            session._is_modified = not session.is_saved

        @event.listens_for(session, 'after_commit')
        @event.listens_for(session, 'after_rollback')
        def init_session_status(session, *args, **kwargs):
            session._is_modified = False
            session._all_changes.clear()

        session.book.session_changes = defaultdict(list)

        session.__class__.is_saved = property(
            fget=lambda self: not (self._is_modified or self.dirty or self.deleted or self.new),
            doc="True if nothing has yet been changed (False otherwise)")

    event.listen(Session, 'before_commit', Book.validate_book)
    event.listen(Session, 'before_flush', Book.track_dirty)