{"action":"created","comment":{"url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/comments/220414459","pull_request_review_id":158816624,"id":220414459,"node_id":"MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDIyMDQxNDQ1OQ==","diff_hunk":"@@ -0,0 +1,533 @@\n+# encoding: utf-8\n+from nose.tools import set_trace\n+\n+import logging\n+import os\n+import warnings\n+from psycopg2.extensions import adapt as sqlescape\n+from psycopg2.extras import NumericRange\n+from sqlalchemy import (\n+    Column,\n+    create_engine,\n+    ForeignKey,\n+    Integer,\n+    Table,\n+)\n+from sqlalchemy.exc import (\n+    IntegrityError,\n+    SAWarning,\n+)\n+from sqlalchemy.ext.declarative import declarative_base\n+from sqlalchemy.orm import (\n+    relationship,\n+    sessionmaker,\n+)\n+from sqlalchemy.orm.exc import (\n+    NoResultFound,\n+    MultipleResultsFound,\n+)\n+from sqlalchemy.sql import (\n+    compiler,\n+    select,\n+)\n+from sqlalchemy.sql.expression import (\n+    literal_column,\n+    table,\n+)\n+\n+Base = declarative_base()\n+\n+from constants import (\n+    DataSourceConstants,\n+    EditionConstants,\n+    IdentifierConstants,\n+    LinkRelations,\n+    MediaTypes,\n+)\n+from .. import classifier\n+\n+def flush(db):\n+    \"\"\"Flush the database connection unless it's known to already be flushing.\"\"\"\n+    is_flushing = False\n+    if hasattr(db, '_flushing'):\n+        # This is a regular database session.\n+        is_flushing = db._flushing\n+    elif hasattr(db, 'registry'):\n+        # This is a flask_scoped_session scoped session.\n+        is_flushing = db.registry()._flushing\n+    else:\n+        logging.error(\"Unknown database connection type: %r\", db)\n+    if not is_flushing:\n+        db.flush()\n+\n+def create(db, model, create_method='',\n+           create_method_kwargs=None,\n+           **kwargs):\n+    kwargs.update(create_method_kwargs or {})\n+    created = getattr(model, create_method, model)(**kwargs)\n+    db.add(created)\n+    flush(db)\n+    return created, True\n+\n+def get_one(db, model, on_multiple='error', constraint=None, **kwargs):\n+    \"\"\"Gets an object from the database based on its attributes.\n+\n+    :param constraint: A single clause that can be passed into\n+        `sqlalchemy.Query.filter` to limit the object that is returned.\n+    :return: object or None\n+    \"\"\"\n+    constraint = constraint\n+    if 'constraint' in kwargs:\n+        constraint = kwargs['constraint']\n+        del kwargs['constraint']\n+\n+    q = db.query(model).filter_by(**kwargs)\n+    if constraint is not None:\n+        q = q.filter(constraint)\n+\n+    try:\n+        return q.one()\n+    except MultipleResultsFound, e:\n+        if on_multiple == 'error':\n+            raise e\n+        elif on_multiple == 'interchangeable':\n+            # These records are interchangeable so we can use\n+            # whichever one we want.\n+            #\n+            # This may be a sign of a problem somewhere else. A\n+            # database-level constraint might be useful.\n+            q = q.limit(1)\n+            return q.one()\n+    except NoResultFound:\n+        return None\n+\n+def get_one_or_create(db, model, create_method='',\n+                      create_method_kwargs=None,\n+                      **kwargs):\n+    one = get_one(db, model, **kwargs)\n+    if one:\n+        return one, False\n+    else:\n+        __transaction = db.begin_nested()\n+        try:\n+            # These kwargs are supported by get_one() but not by create().\n+            get_one_keys = ['on_multiple', 'constraint']\n+            for key in get_one_keys:\n+                if key in kwargs:\n+                    del kwargs[key]\n+            obj = create(db, model, create_method, create_method_kwargs, **kwargs)\n+            __transaction.commit()\n+            return obj\n+        except IntegrityError, e:\n+            logging.info(\n+                \"INTEGRITY ERROR on %r %r, %r: %r\", model, create_method_kwargs,\n+                kwargs, e)\n+            __transaction.rollback()\n+            return db.query(model).filter_by(**kwargs).one(), False\n+\n+def numericrange_to_string(r):\n+    \"\"\"Helper method to convert a NumericRange to a human-readable string.\"\"\"\n+    if not r:\n+        return \"\"\n+    lower = r.lower\n+    upper = r.upper\n+    if upper is None and lower is None:\n+        return \"\"\n+    if lower and upper is None:\n+        return str(lower)\n+    if upper and lower is None:\n+        return str(upper)\n+    if not r.upper_inc:\n+        upper -= 1\n+    if not r.lower_inc:\n+        lower += 1\n+    if upper == lower:\n+        return str(lower)\n+    return \"%s-%s\" % (lower,upper)\n+\n+def numericrange_to_tuple(r):\n+    \"\"\"Helper method to normalize NumericRange into a tuple.\"\"\"\n+    if r is None:\n+        return (None, None)\n+    lower = r.lower\n+    upper = r.upper\n+    if lower and not r.lower_inc:\n+        lower -= 1\n+    if upper and not r.upper_inc:\n+        upper -= 1\n+    return lower, upper\n+\n+def tuple_to_numericrange(t):\n+    \"\"\"Helper method to convert a tuple to an inclusive NumericRange.\"\"\"\n+    if not t:\n+        return None\n+    return NumericRange(t[0], t[1], '[]')\n+\n+class PresentationCalculationPolicy(object):\n+    \"\"\"Which parts of the Work or Edition's presentation\n+    are we actually looking to update?\n+    \"\"\"\n+    def __init__(self,\n+                 choose_edition=True,\n+                 set_edition_metadata=True,\n+                 classify=True,\n+                 choose_summary=True,\n+                 calculate_quality=True,\n+                 choose_cover=True,\n+                 regenerate_opds_entries=False,\n+                 update_search_index=False,\n+                 verbose=True,\n+    ):\n+        self.choose_edition = choose_edition\n+        self.set_edition_metadata = set_edition_metadata\n+        self.classify = classify\n+        self.choose_summary=choose_summary\n+        self.calculate_quality=calculate_quality\n+        self.choose_cover = choose_cover\n+\n+        # We will regenerate OPDS entries if any of the metadata\n+        # changes, but if regenerate_opds_entries is True we will\n+        # _always_ do so. This is so we can regenerate _all_ the OPDS\n+        # entries if the OPDS presentation algorithm changes.\n+        self.regenerate_opds_entries = regenerate_opds_entries\n+\n+        # Similarly for update_search_index.\n+        self.update_search_index = update_search_index\n+\n+        self.verbose = verbose\n+\n+    @classmethod\n+    def recalculate_everything(cls):\n+        \"\"\"A PresentationCalculationPolicy that always recalculates\n+        everything, even when it doesn't seem necessary.\n+        \"\"\"\n+        return PresentationCalculationPolicy(\n+            regenerate_opds_entries=True,\n+            update_search_index=True,\n+        )\n+\n+    @classmethod\n+    def reset_cover(cls):\n+        \"\"\"A PresentationCalculationPolicy that only resets covers\n+        (including updating cached entries, if necessary) without\n+        impacting any other metadata.\n+        \"\"\"\n+        return cls(\n+            choose_cover=True,\n+            choose_edition=False,\n+            set_edition_metadata=False,\n+            classify=False,\n+            choose_summary=False,\n+            calculate_quality=False\n+        )\n+\n+def dump_query(query):\n+    dialect = query.session.bind.dialect\n+    statement = query.statement\n+    comp = compiler.SQLCompiler(dialect, statement)\n+    comp.compile()\n+    enc = dialect.encoding\n+    params = {}\n+    for k,v in comp.params.iteritems():\n+        if isinstance(v, unicode):\n+            v = v.encode(enc)\n+        params[k] = sqlescape(v)\n+    return (comp.string.encode(enc) % params).decode(enc)\n+\n+DEBUG = False\n+\n+class SessionManager(object):\n+\n+    # Materialized views need to be created and indexed from SQL\n+    # commands kept in files. This dictionary maps the views to the\n+    # SQL files.\n+\n+    MATERIALIZED_VIEW_WORKS = 'mv_works_editions_datasources_identifiers'\n+    MATERIALIZED_VIEW_WORKS_WORKGENRES = 'mv_works_editions_workgenres_datasources_identifiers'\n+    MATERIALIZED_VIEW_LANES = 'mv_works_for_lanes'\n+    MATERIALIZED_VIEWS = {\n+        #MATERIALIZED_VIEW_WORKS : 'materialized_view_works.sql',\n+        #MATERIALIZED_VIEW_WORKS_WORKGENRES : 'materialized_view_works_workgenres.sql',\n+        MATERIALIZED_VIEW_LANES : 'materialized_view_for_lanes.sql',\n+    }\n+\n+    # A function that calculates recursively equivalent identifiers\n+    # is also defined in SQL.\n+    RECURSIVE_EQUIVALENTS_FUNCTION = 'recursive_equivalents.sql'\n+\n+    engine_for_url = {}\n+\n+    @classmethod\n+    def engine(cls, url=None):\n+        url = url or Configuration.database_url()\n+        return create_engine(url, echo=DEBUG)\n+\n+    @classmethod\n+    def sessionmaker(cls, url=None, session=None):\n+        if not (url or session):\n+            url = Configuration.database_url()\n+        if url:\n+            bind_obj = cls.engine(url)\n+        elif session:\n+            bind_obj = session.get_bind()\n+            if not os.environ.get('TESTING'):\n+                # If a factory is being created from a session in test mode,\n+                # use the same Connection for all of the tests so objects can\n+                # be accessed. Otherwise, bind against an Engine object.\n+                bind_obj = bind_obj.engine\n+        return sessionmaker(bind=bind_obj)\n+\n+    @classmethod\n+    def resource_directory(cls):\n+        \"\"\"The directory containing SQL files used in database setup.\"\"\"\n+        base_path = os.path.split(__file__)[0]\n+        return os.path.join(base_path, \"files\")\n+\n+    @classmethod\n+    def initialize(cls, url):\n+        \"\"\"Initialize the database.\n+\n+        This includes the schema, the materialized views, the custom\n+        functions, and the initial content.\n+        \"\"\"\n+        if url in cls.engine_for_url:\n+            engine = cls.engine_for_url[url]\n+            return engine, engine.connect()\n+\n+        engine = cls.engine(url)\n+        cls.initialize_schema(engine)\n+        connection = engine.connect()\n+\n+        # Check if the recursive equivalents function exists already.\n+        query = select(\n+            [literal_column('proname')]\n+        ).select_from(\n+            table('pg_proc')\n+        ).where(\n+            literal_column('proname')=='fn_recursive_equivalents'\n+        )\n+        result = connection.execute(query)\n+        result = list(result)\n+\n+        # If it doesn't, create it.\n+        if not result:\n+            resource_file = os.path.join(\n+                cls.resource_directory(), cls.RECURSIVE_EQUIVALENTS_FUNCTION\n+            )\n+            if not os.path.exists(resource_file):\n+                raise IOError(\"Could not load recursive equivalents function from %s: file does not exist.\" % resource_file)\n+            sql = open(resource_file).read()\n+            connection.execute(sql)\n+\n+        session = Session(connection)\n+        cls.initialize_data(session)\n+\n+        if connection:\n+            connection.close()\n+\n+        cls.engine_for_url[url] = engine\n+        return engine, engine.connect()\n+\n+    @classmethod\n+    def initialize_schema(cls, engine):\n+        \"\"\"Initialize the database schema.\"\"\"\n+        # Use SQLAlchemy to create all the tables.\n+        to_create = [\n+            table_obj for name, table_obj in Base.metadata.tables.items()\n+            if not name.startswith('mv_')\n+        ]\n+        Base.metadata.create_all(engine, tables=to_create)\n+\n+        # Create the materialized view with raw SQL.\n+        connection = None\n+        for view_name, filename in cls.MATERIALIZED_VIEWS.items():\n+            if engine.has_table(view_name):\n+                continue\n+            if not connection:\n+                connection = engine.connect()\n+            resource_file = os.path.join(cls.resource_directory(), filename)\n+            if not os.path.exists(resource_file):\n+                raise IOError(\"Could not load materialized view from %s: file does not exist.\" % resource_file)\n+            logging.info(\n+                \"Loading materialized view %s from %s.\",\n+                view_name, resource_file)\n+            sql = open(resource_file).read()\n+            connection.execution_options(isolation_level='AUTOCOMMIT')\\\n+                      .execute(text(sql))\n+\n+            # NOTE: This is apparently necessary for the creation of\n+            # the materialized view to be finalized in all cases. As\n+            # such, materialized views should be created WITH NO DATA,\n+            # since they will be refreshed immediately after creation.\n+            result = connection.execute(\n+                \"REFRESH MATERIALIZED VIEW %s;\" % view_name\n+            )\n+\n+    @classmethod\n+    def refresh_materialized_views(self, _db):\n+        for view_name in self.MATERIALIZED_VIEWS.keys():\n+            _db.execute(\"refresh materialized view %s;\" % view_name)\n+            _db.commit()\n+        # Immediately update the number of works associated with each\n+        # lane.\n+        from ..lane import Lane\n+        for lane in _db.query(Lane):\n+            lane.update_size(_db)\n+\n+    @classmethod\n+    def session(cls, url, initialize_data=True):\n+        engine = connection = 0\n+        with warnings.catch_warnings():\n+            warnings.simplefilter(\"ignore\", category=SAWarning)\n+            engine, connection = cls.initialize(url)\n+        session = Session(connection)\n+        if initialize_data:\n+            session = cls.initialize_data(session)\n+        return session\n+\n+    @classmethod\n+    def initialize_data(cls, session, set_site_configuration=True):\n+        # Create initial content.\n+        from datasource import DataSource\n+        from classification import Genre\n+        from licensing import DeliveryMechanism\n+        list(DataSource.well_known_sources(session))\n+\n+        # Load all existing Genre objects.\n+        Genre.populate_cache(session)\n+\n+        # Create any genres not in the database.\n+        for g in classifier.genres.values():\n+            # TODO: On the very first startup this is rather expensive\n+            # because the cache is invalidated every time a Genre is\n+            # created, then populated the next time a Genre is looked\n+            # up. This wouldn't be a big problem, but this also happens\n+            # on setup for the unit tests.\n+            Genre.lookup(session, g, autocreate=True)\n+\n+        # Make sure that the mechanisms fulfillable by the default\n+        # client are marked as such.\n+        for content_type, drm_scheme in DeliveryMechanism.default_client_can_fulfill_lookup:\n+            mechanism, is_new = DeliveryMechanism.lookup(\n+                session, content_type, drm_scheme\n+            )\n+            mechanism.default_client_can_fulfill = True\n+\n+        # If there is currently no 'site configuration change'\n+        # Timestamp in the database, create one.\n+        timestamp, is_new = get_one_or_create(\n+            session, Timestamp, collection=None,\n+            service=Configuration.SITE_CONFIGURATION_CHANGED,\n+            create_method_kwargs=dict(timestamp=datetime.datetime.utcnow())\n+        )\n+        if is_new:\n+            from listeners import site_configuration_has_changed\n+            site_configuration_has_changed(session)\n+        session.commit()\n+\n+        # Return a potentially-new Session object in case\n+        # it was updated by cls.update_timestamps_table\n+        return session\n+\n+def production_session():\n+    url = Configuration.database_url()\n+    if url.startswith('\"'):\n+        url = url[1:]\n+    logging.debug(\"Database url: %s\", url)\n+    _db = SessionManager.session(url)\n+\n+    # The first thing to do after getting a database connection is to\n+    # set up the logging configuration.\n+    #\n+    # If called during a unit test, this will configure logging\n+    # incorrectly, but 1) this method isn't normally called during\n+    # unit tests, and 2) package_setup() will call initialize() again\n+    # with the right arguments.\n+    from ..log import LogConfiguration\n+    LogConfiguration.initialize(_db)\n+    return _db\n+\n+from admin import (\n+    Admin,\n+    AdminRole,\n+)\n+from background import (\n+    BaseCoverageRecord,\n+    CoverageRecord,\n+    Timestamp,\n+    WorkCoverageRecord,\n+)\n+from cachedfeed import (\n+    CachedFeed,\n+    WillNotGenerateExpensiveFeed,\n+)\n+from circulationevent import CirculationEvent\n+from classification import (\n+    Classification,\n+    Genre,\n+    Subject,\n+)\n+from collection import (\n+    Collection,\n+    CollectionIdentifier,\n+    CollectionMissing,\n+    collections_identifiers,\n+)\n+from configuration import (\n+    ConfigurationSetting,\n+    ExternalIntegration,\n+)\n+from complaint import Complaint\n+from contributor import (\n+    Contribution,\n+    Contributor,\n+    WorkContribution,\n+)\n+from credential import (\n+    Credential,\n+    DelegatedPatronIdentifier,\n+    DRMDeviceIdentifier,\n+)\n+from customlist import (\n+    CustomList,\n+    CustomListEntry,\n+)\n+from datasource import DataSource\n+from edition import Edition\n+from hasfulltablecache import HasFullTableCache\n+from identifier import (\n+    Equivalency,\n+    Identifier,\n+)\n+from integrationclient import IntegrationClient\n+from library import Library\n+from licensing import (\n+    DeliveryMechanism,\n+    LicensePool,\n+    LicensePoolDeliveryMechanism,\n+    PolicyException,\n+    RightsStatus,\n+)\n+from measurement import Measurement\n+from patron import (\n+    Annotation,\n+    Hold,\n+    Loan,\n+    LoanAndHoldMixin,\n+    Patron,\n+    PatronProfileStorage,\n+)\n+import listeners\n+from listeners import *","path":"model/__init__.py","position":521,"original_position":521,"commit_id":"50efe5e7057e31ec5455afb36530a57b6596fa36","original_commit_id":"50efe5e7057e31ec5455afb36530a57b6596fa36","user":{"login":"aslagle","id":3409215,"node_id":"MDQ6VXNlcjM0MDkyMTU=","avatar_url":"https://avatars3.githubusercontent.com/u/3409215?v=4","gravatar_id":"","url":"https://api.github.com/users/aslagle","html_url":"https://github.com/aslagle","followers_url":"https://api.github.com/users/aslagle/followers","following_url":"https://api.github.com/users/aslagle/following{/other_user}","gists_url":"https://api.github.com/users/aslagle/gists{/gist_id}","starred_url":"https://api.github.com/users/aslagle/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/aslagle/subscriptions","organizations_url":"https://api.github.com/users/aslagle/orgs","repos_url":"https://api.github.com/users/aslagle/repos","events_url":"https://api.github.com/users/aslagle/events{/privacy}","received_events_url":"https://api.github.com/users/aslagle/received_events","type":"User","site_admin":false},"body":"Why is this one imported two ways?","created_at":"2018-09-26T03:02:16Z","updated_at":"2018-09-26T03:02:16Z","html_url":"https://github.com/NYPL-Simplified/server_core/pull/948#discussion_r220414459","pull_request_url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948","author_association":"COLLABORATOR","_links":{"self":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/comments/220414459"},"html":{"href":"https://github.com/NYPL-Simplified/server_core/pull/948#discussion_r220414459"},"pull_request":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948"}}},"pull_request":{"url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948","id":215064414,"node_id":"MDExOlB1bGxSZXF1ZXN0MjE1MDY0NDE0","html_url":"https://github.com/NYPL-Simplified/server_core/pull/948","diff_url":"https://github.com/NYPL-Simplified/server_core/pull/948.diff","patch_url":"https://github.com/NYPL-Simplified/server_core/pull/948.patch","issue_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/948","number":948,"state":"open","locked":false,"title":"Refactor model.py into multiple files","user":{"login":"leonardr","id":662474,"node_id":"MDQ6VXNlcjY2MjQ3NA==","avatar_url":"https://avatars0.githubusercontent.com/u/662474?v=4","gravatar_id":"","url":"https://api.github.com/users/leonardr","html_url":"https://github.com/leonardr","followers_url":"https://api.github.com/users/leonardr/followers","following_url":"https://api.github.com/users/leonardr/following{/other_user}","gists_url":"https://api.github.com/users/leonardr/gists{/gist_id}","starred_url":"https://api.github.com/users/leonardr/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/leonardr/subscriptions","organizations_url":"https://api.github.com/users/leonardr/orgs","repos_url":"https://api.github.com/users/leonardr/repos","events_url":"https://api.github.com/users/leonardr/events{/privacy}","received_events_url":"https://api.github.com/users/leonardr/received_events","type":"User","site_admin":false},"body":"This branch converts `model.py`, a huge monolithic file, into `model/`, a package containing a large number of files. Similarly for `tests/test_model.py`, which becomes `tests/models/`.\r\n\r\nThe major change that had to be made to support this is: we can no longer write code like this from the `tests` package or the `model` package:\r\n\r\n```\r\nfrom config import Configuration\r\n```\r\n\r\nInstead we have to do a relative import:\r\n\r\n```\r\nfrom ..config import Configuration\r\n```\r\n\r\nOr even (if we're in tests/models):\r\n\r\n```\r\nfrom ...config import Configuration\r\n```\r\n\r\nPreviously we avoided this by adding the top-level module to `sys.path` when running tests. This caused problems in circulation, though. When we wrote code like this in circulation, we ended up with a class called `core.model.LicensePool`:\r\n\r\n```\r\nfrom core.model import LicensePool\r\n```\r\n\r\nBut that circulation code could cause code like this to run within the model package, whereupon we end up with a class called `model.Edition`:\r\n\r\n```\r\nfrom model import Edition\r\n```\r\n\r\nThis is what Nick Coghlan refers to as [the double import trap](https://python-notes.curiousefficiency.org/en/latest/python_concepts/import_traps.html#the-double-import-trap). SQLAlchemy was continually checking things like `if core.model.Edition is model.Edition`, and all of those checks were failing.\r\n\r\nInstituting a \"don't mess with `sys.path`\" rule meant we had to change a lot of our imports inside core, but once that change was made, the changes necessary to circulation and metadata were very small.\r\n\r\nUsing a relative import ensures that when we're running the core tests we always have a `model.LicensePool` object, and when we're running circulation or metadata code we always have a `core.model.LicensePool` object.\r\n\r\nOther changes:\r\n\r\n* Test setup reliably destroys and recreates the entire database schema, including the materialized views.\r\n* The `MaterializedWorkWithGenre` class now has a schema defined up front rather than obtained by looking at a real materialized view at runtime. This simplifies startup and lets us import `MaterializedWorkWithGenre` at the same time we import, say, `LicensePool`, instead of having to wait for later.","created_at":"2018-09-12T19:05:53Z","updated_at":"2018-09-26T03:02:16Z","closed_at":null,"merged_at":null,"merge_commit_sha":"35cb172a8f935c508d84d06964c5c0b17bf589a5","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948/commits","review_comments_url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948/comments","review_comment_url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/comments{/number}","comments_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/948/comments","statuses_url":"https://api.github.com/repos/NYPL-Simplified/server_core/statuses/50efe5e7057e31ec5455afb36530a57b6596fa36","head":{"label":"NYPL-Simplified:refactor-model","ref":"refactor-model","sha":"50efe5e7057e31ec5455afb36530a57b6596fa36","user":{"login":"NYPL-Simplified","id":16416905,"node_id":"MDEyOk9yZ2FuaXphdGlvbjE2NDE2OTA1","avatar_url":"https://avatars1.githubusercontent.com/u/16416905?v=4","gravatar_id":"","url":"https://api.github.com/users/NYPL-Simplified","html_url":"https://github.com/NYPL-Simplified","followers_url":"https://api.github.com/users/NYPL-Simplified/followers","following_url":"https://api.github.com/users/NYPL-Simplified/following{/other_user}","gists_url":"https://api.github.com/users/NYPL-Simplified/gists{/gist_id}","starred_url":"https://api.github.com/users/NYPL-Simplified/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/NYPL-Simplified/subscriptions","organizations_url":"https://api.github.com/users/NYPL-Simplified/orgs","repos_url":"https://api.github.com/users/NYPL-Simplified/repos","events_url":"https://api.github.com/users/NYPL-Simplified/events{/privacy}","received_events_url":"https://api.github.com/users/NYPL-Simplified/received_events","type":"Organization","site_admin":false},"repo":{"id":27601287,"node_id":"MDEwOlJlcG9zaXRvcnkyNzYwMTI4Nw==","name":"server_core","full_name":"NYPL-Simplified/server_core","private":false,"owner":{"login":"NYPL-Simplified","id":16416905,"node_id":"MDEyOk9yZ2FuaXphdGlvbjE2NDE2OTA1","avatar_url":"https://avatars1.githubusercontent.com/u/16416905?v=4","gravatar_id":"","url":"https://api.github.com/users/NYPL-Simplified","html_url":"https://github.com/NYPL-Simplified","followers_url":"https://api.github.com/users/NYPL-Simplified/followers","following_url":"https://api.github.com/users/NYPL-Simplified/following{/other_user}","gists_url":"https://api.github.com/users/NYPL-Simplified/gists{/gist_id}","starred_url":"https://api.github.com/users/NYPL-Simplified/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/NYPL-Simplified/subscriptions","organizations_url":"https://api.github.com/users/NYPL-Simplified/orgs","repos_url":"https://api.github.com/users/NYPL-Simplified/repos","events_url":"https://api.github.com/users/NYPL-Simplified/events{/privacy}","received_events_url":"https://api.github.com/users/NYPL-Simplified/received_events","type":"Organization","site_admin":false},"html_url":"https://github.com/NYPL-Simplified/server_core","description":"Shared data model and utilities for Library Simplified server applications","fork":false,"url":"https://api.github.com/repos/NYPL-Simplified/server_core","forks_url":"https://api.github.com/repos/NYPL-Simplified/server_core/forks","keys_url":"https://api.github.com/repos/NYPL-Simplified/server_core/keys{/key_id}","collaborators_url":"https://api.github.com/repos/NYPL-Simplified/server_core/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/NYPL-Simplified/server_core/teams","hooks_url":"https://api.github.com/repos/NYPL-Simplified/server_core/hooks","issue_events_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/events{/number}","events_url":"https://api.github.com/repos/NYPL-Simplified/server_core/events","assignees_url":"https://api.github.com/repos/NYPL-Simplified/server_core/assignees{/user}","branches_url":"https://api.github.com/repos/NYPL-Simplified/server_core/branches{/branch}","tags_url":"https://api.github.com/repos/NYPL-Simplified/server_core/tags","blobs_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/refs{/sha}","trees_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/trees{/sha}","statuses_url":"https://api.github.com/repos/NYPL-Simplified/server_core/statuses/{sha}","languages_url":"https://api.github.com/repos/NYPL-Simplified/server_core/languages","stargazers_url":"https://api.github.com/repos/NYPL-Simplified/server_core/stargazers","contributors_url":"https://api.github.com/repos/NYPL-Simplified/server_core/contributors","subscribers_url":"https://api.github.com/repos/NYPL-Simplified/server_core/subscribers","subscription_url":"https://api.github.com/repos/NYPL-Simplified/server_core/subscription","commits_url":"https://api.github.com/repos/NYPL-Simplified/server_core/commits{/sha}","git_commits_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/commits{/sha}","comments_url":"https://api.github.com/repos/NYPL-Simplified/server_core/comments{/number}","issue_comment_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/comments{/number}","contents_url":"https://api.github.com/repos/NYPL-Simplified/server_core/contents/{+path}","compare_url":"https://api.github.com/repos/NYPL-Simplified/server_core/compare/{base}...{head}","merges_url":"https://api.github.com/repos/NYPL-Simplified/server_core/merges","archive_url":"https://api.github.com/repos/NYPL-Simplified/server_core/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/NYPL-Simplified/server_core/downloads","issues_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues{/number}","pulls_url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls{/number}","milestones_url":"https://api.github.com/repos/NYPL-Simplified/server_core/milestones{/number}","notifications_url":"https://api.github.com/repos/NYPL-Simplified/server_core/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/NYPL-Simplified/server_core/labels{/name}","releases_url":"https://api.github.com/repos/NYPL-Simplified/server_core/releases{/id}","deployments_url":"https://api.github.com/repos/NYPL-Simplified/server_core/deployments","created_at":"2014-12-05T17:03:04Z","updated_at":"2018-09-25T15:56:45Z","pushed_at":"2018-09-25T20:01:18Z","git_url":"git://github.com/NYPL-Simplified/server_core.git","ssh_url":"git@github.com:NYPL-Simplified/server_core.git","clone_url":"https://github.com/NYPL-Simplified/server_core.git","svn_url":"https://github.com/NYPL-Simplified/server_core","homepage":null,"size":12603,"stargazers_count":3,"watchers_count":3,"language":"Python","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":10,"mirror_url":null,"archived":false,"open_issues_count":63,"license":null,"forks":10,"open_issues":63,"watchers":3,"default_branch":"master"}},"base":{"label":"NYPL-Simplified:master","ref":"master","sha":"0ec7304ff13fe0084a3f2aad5a1d6fd2aa6882fc","user":{"login":"NYPL-Simplified","id":16416905,"node_id":"MDEyOk9yZ2FuaXphdGlvbjE2NDE2OTA1","avatar_url":"https://avatars1.githubusercontent.com/u/16416905?v=4","gravatar_id":"","url":"https://api.github.com/users/NYPL-Simplified","html_url":"https://github.com/NYPL-Simplified","followers_url":"https://api.github.com/users/NYPL-Simplified/followers","following_url":"https://api.github.com/users/NYPL-Simplified/following{/other_user}","gists_url":"https://api.github.com/users/NYPL-Simplified/gists{/gist_id}","starred_url":"https://api.github.com/users/NYPL-Simplified/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/NYPL-Simplified/subscriptions","organizations_url":"https://api.github.com/users/NYPL-Simplified/orgs","repos_url":"https://api.github.com/users/NYPL-Simplified/repos","events_url":"https://api.github.com/users/NYPL-Simplified/events{/privacy}","received_events_url":"https://api.github.com/users/NYPL-Simplified/received_events","type":"Organization","site_admin":false},"repo":{"id":27601287,"node_id":"MDEwOlJlcG9zaXRvcnkyNzYwMTI4Nw==","name":"server_core","full_name":"NYPL-Simplified/server_core","private":false,"owner":{"login":"NYPL-Simplified","id":16416905,"node_id":"MDEyOk9yZ2FuaXphdGlvbjE2NDE2OTA1","avatar_url":"https://avatars1.githubusercontent.com/u/16416905?v=4","gravatar_id":"","url":"https://api.github.com/users/NYPL-Simplified","html_url":"https://github.com/NYPL-Simplified","followers_url":"https://api.github.com/users/NYPL-Simplified/followers","following_url":"https://api.github.com/users/NYPL-Simplified/following{/other_user}","gists_url":"https://api.github.com/users/NYPL-Simplified/gists{/gist_id}","starred_url":"https://api.github.com/users/NYPL-Simplified/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/NYPL-Simplified/subscriptions","organizations_url":"https://api.github.com/users/NYPL-Simplified/orgs","repos_url":"https://api.github.com/users/NYPL-Simplified/repos","events_url":"https://api.github.com/users/NYPL-Simplified/events{/privacy}","received_events_url":"https://api.github.com/users/NYPL-Simplified/received_events","type":"Organization","site_admin":false},"html_url":"https://github.com/NYPL-Simplified/server_core","description":"Shared data model and utilities for Library Simplified server applications","fork":false,"url":"https://api.github.com/repos/NYPL-Simplified/server_core","forks_url":"https://api.github.com/repos/NYPL-Simplified/server_core/forks","keys_url":"https://api.github.com/repos/NYPL-Simplified/server_core/keys{/key_id}","collaborators_url":"https://api.github.com/repos/NYPL-Simplified/server_core/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/NYPL-Simplified/server_core/teams","hooks_url":"https://api.github.com/repos/NYPL-Simplified/server_core/hooks","issue_events_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/events{/number}","events_url":"https://api.github.com/repos/NYPL-Simplified/server_core/events","assignees_url":"https://api.github.com/repos/NYPL-Simplified/server_core/assignees{/user}","branches_url":"https://api.github.com/repos/NYPL-Simplified/server_core/branches{/branch}","tags_url":"https://api.github.com/repos/NYPL-Simplified/server_core/tags","blobs_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/refs{/sha}","trees_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/trees{/sha}","statuses_url":"https://api.github.com/repos/NYPL-Simplified/server_core/statuses/{sha}","languages_url":"https://api.github.com/repos/NYPL-Simplified/server_core/languages","stargazers_url":"https://api.github.com/repos/NYPL-Simplified/server_core/stargazers","contributors_url":"https://api.github.com/repos/NYPL-Simplified/server_core/contributors","subscribers_url":"https://api.github.com/repos/NYPL-Simplified/server_core/subscribers","subscription_url":"https://api.github.com/repos/NYPL-Simplified/server_core/subscription","commits_url":"https://api.github.com/repos/NYPL-Simplified/server_core/commits{/sha}","git_commits_url":"https://api.github.com/repos/NYPL-Simplified/server_core/git/commits{/sha}","comments_url":"https://api.github.com/repos/NYPL-Simplified/server_core/comments{/number}","issue_comment_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/comments{/number}","contents_url":"https://api.github.com/repos/NYPL-Simplified/server_core/contents/{+path}","compare_url":"https://api.github.com/repos/NYPL-Simplified/server_core/compare/{base}...{head}","merges_url":"https://api.github.com/repos/NYPL-Simplified/server_core/merges","archive_url":"https://api.github.com/repos/NYPL-Simplified/server_core/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/NYPL-Simplified/server_core/downloads","issues_url":"https://api.github.com/repos/NYPL-Simplified/server_core/issues{/number}","pulls_url":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls{/number}","milestones_url":"https://api.github.com/repos/NYPL-Simplified/server_core/milestones{/number}","notifications_url":"https://api.github.com/repos/NYPL-Simplified/server_core/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/NYPL-Simplified/server_core/labels{/name}","releases_url":"https://api.github.com/repos/NYPL-Simplified/server_core/releases{/id}","deployments_url":"https://api.github.com/repos/NYPL-Simplified/server_core/deployments","created_at":"2014-12-05T17:03:04Z","updated_at":"2018-09-25T15:56:45Z","pushed_at":"2018-09-25T20:01:18Z","git_url":"git://github.com/NYPL-Simplified/server_core.git","ssh_url":"git@github.com:NYPL-Simplified/server_core.git","clone_url":"https://github.com/NYPL-Simplified/server_core.git","svn_url":"https://github.com/NYPL-Simplified/server_core","homepage":null,"size":12603,"stargazers_count":3,"watchers_count":3,"language":"Python","has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":10,"mirror_url":null,"archived":false,"open_issues_count":63,"license":null,"forks":10,"open_issues":63,"watchers":3,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948"},"html":{"href":"https://github.com/NYPL-Simplified/server_core/pull/948"},"issue":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/948"},"comments":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/issues/948/comments"},"review_comments":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948/comments"},"review_comment":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/pulls/948/commits"},"statuses":{"href":"https://api.github.com/repos/NYPL-Simplified/server_core/statuses/50efe5e7057e31ec5455afb36530a57b6596fa36"}},"author_association":"CONTRIBUTOR"}}
{"actor":{"display_login":"aslagle"}}