Repository: pybel/pybel

Issue:
Add OBO-style name/identifier parsing
As a proof of concept, try parsing the identifier and name delimited by a `!` as in `p(HGNC:6893!MAPT)`. Quotes should be allowed on both the identifier and the name.

Should whitespace should be allowed?

`p(HGNC:6893 ! MAPT)`



Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/384?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`develop@7e5b3cc`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/pybel/pybel/pull/384/graphs/tree.svg?width=650&token=J7joRTRygG&height=150&src=pr)](https://codecov.io/gh/pybel/pybel/pull/384?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##             develop     #384   +/-   ##
==========================================
  Coverage           ?   87.14%           
==========================================
  Files              ?      133           
  Lines              ?     6401           
  Branches           ?      850           
==========================================
  Hits               ?     5578           
  Misses             ?      637           
  Partials           ?      186
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/384?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/tokens.py](https://codecov.io/gh/pybel/pybel/pull/384/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3Rva2Vucy5weQ==) | `86.88% <ø> (ø)` | |
| [src/pybel/parser/parse\_identifier.py](https://codecov.io/gh/pybel/pybel/pull/384/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3BhcnNlci9wYXJzZV9pZGVudGlmaWVyLnB5) | `94.11% <100%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/384?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pybel/pybel/pull/384?src=pr&el=footer). Last update [7e5b3cc...d652eec](https://codecov.io/gh/pybel/pybel/pull/384?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/pybel/parser/parse_concept.py b/src/pybel/parser/parse_concept.py
index 0ea18dda..c8e94cad 100644
--- a/src/pybel/parser/parse_concept.py
+++ b/src/pybel/parser/parse_concept.py
@@ -13,7 +13,7 @@ from .exc import (
     UndefinedNamespaceWarning,
 )
 from .utils import quote, word
-from ..constants import DIRTY, NAME, NAMESPACE
+from ..constants import DIRTY, IDENTIFIER, NAME, NAMESPACE
 
 __all__ = [
     'ConceptParser',
@@ -47,9 +47,18 @@ class ConceptParser(BaseParser):
         self.default_namespace = set(default_namespace) if default_namespace is not None else None
         self.allow_naked_names = allow_naked_names
 
+        self.identifier_fqualified = (
+            word(NAMESPACE) +
+            Suppress(':') +
+            (word | quote)(IDENTIFIER) +
+            Suppress('!') +
+            (word | quote)(NAME)
+        )
+
         self.identifier_qualified = word(NAMESPACE) + Suppress(':') + (word | quote)(NAME)
 
         if self.namespace_to_terms:
+            self.identifier_fqualified.setParseAction(self.handle_identifier_qualified)
             self.identifier_qualified.setParseAction(self.handle_identifier_qualified)
 
         self.identifier_bare = (word | quote)(NAME)
@@ -60,7 +69,9 @@ class ConceptParser(BaseParser):
             self.handle_namespace_invalid
         )
 
-        super(ConceptParser, self).__init__(self.identifier_qualified | self.identifier_bare)
+        super().__init__(
+            self.identifier_fqualified | self.identifier_qualified | self.identifier_bare
+        )
 
     def has_enumerated_namespace(self, namespace: str) -> bool:
         """Check that the namespace has been defined by an enumeration."""


Test patch:
diff --git a/tests/test_parse/test_parse_bel.py b/tests/test_parse/test_parse_bel.py
index 4a599f98..e76cc71d 100644
--- a/tests/test_parse/test_parse_bel.py
+++ b/tests/test_parse/test_parse_bel.py
@@ -9,7 +9,7 @@ from pybel import BELGraph
 from pybel.constants import (
     ABUNDANCE, ACTIVITY, BEL_DEFAULT_NAMESPACE, BIOPROCESS, COMPLEX, COMPOSITE, CONCEPT, DEGRADATION,
     DIRECTLY_INCREASES, DIRTY, EFFECT, FRAGMENT, FROM_LOC, FUNCTION, FUSION, FUSION_MISSING, FUSION_REFERENCE,
-    FUSION_START, FUSION_STOP, GENE, HAS_COMPONENT, HAS_VARIANT, HGVS, KIND, LOCATION, MEMBERS, MIRNA,
+    FUSION_START, FUSION_STOP, GENE, HAS_COMPONENT, HAS_VARIANT, HGVS, IDENTIFIER, KIND, LOCATION, MEMBERS, MIRNA,
     MODIFIER, NAME, NAMESPACE, OBJECT, PARTNER_3P, PARTNER_5P, PATHOLOGY, PRODUCTS, PROTEIN, RANGE_3P, RANGE_5P,
     REACTANTS, REACTION, RELATION, RNA, SUBJECT, TARGET, TO_LOC, TRANSLOCATION, VARIANTS,
 )
@@ -89,6 +89,71 @@ class TestAbundance(TestTokenParserBase):
         self._test_abundance_with_location_helper('abundance(CHEBI:"oxygen atom", location(GO:intracellular))')
 
 
+class TestAbundanceLabeled(TestTokenParserBase):
+    """2.1.1"""
+
+    def setUp(self):
+        self.parser.clear()
+        self.parser.general_abundance.setParseAction(self.parser.handle_term)
+
+        self.expected_node_data = abundance(namespace='chebi', name='oxygen atom', identifier='CHEBI:25805')
+        self.expected_canonical_bel = 'a(chebi:"oxygen atom")'
+
+    def _test_abundance_helper(self, statement):
+        result = self.parser.general_abundance.parseString(statement)
+        self.assertEqual(dict(self.expected_node_data), result.asDict())
+
+        self.assertIn(self.expected_node_data, self.graph)
+        self.assertEqual(self.expected_canonical_bel, self.graph.node_to_bel(self.expected_node_data))
+
+        self.assertEqual({}, modifier_po_to_dict(result), msg='The modifier dictionary should be empty')
+
+    def test_abundance(self):
+        """Test short/long abundance name."""
+        self._test_abundance_helper('a(chebi:"CHEBI:25805"!"oxygen atom")')
+        self._test_abundance_helper('abundance(chebi:"CHEBI:25805"!"oxygen atom")')
+        self._test_abundance_helper('abundance(chebi:"CHEBI:25805" ! "oxygen atom")')
+
+    def _test_abundance_with_location_helper(self, statement):
+        result = self.parser.general_abundance.parseString(statement)
+
+        expected_result = {
+            FUNCTION: ABUNDANCE,
+            CONCEPT: {
+                NAMESPACE: 'chebi',
+                NAME: 'oxygen atom',
+                IDENTIFIER: 'CHEBI:25805',
+            },
+            LOCATION: {
+                NAMESPACE: 'GO',
+                NAME: 'intracellular',
+            }
+        }
+
+        self.assertEqual(expected_result, result.asDict())
+
+        self.assertIn(self.expected_node_data, self.graph)
+        self.assertEqual(self.expected_canonical_bel, self.graph.node_to_bel(self.expected_node_data))
+
+        modifier = modifier_po_to_dict(result)
+        expected_modifier = {
+            LOCATION: {
+                NAMESPACE: 'GO',
+                NAME: 'intracellular',
+            }
+        }
+        self.assertEqual(expected_modifier, modifier)
+
+    def test_abundance_with_location(self):
+        """Test short/long abundance name and short/long location name."""
+        self._test_abundance_with_location_helper('a(chebi:"CHEBI:25805"!"oxygen atom", loc(GO:intracellular))')
+        self._test_abundance_with_location_helper('abundance(chebi:"CHEBI:25805"!"oxygen atom", loc(GO:intracellular))')
+        self._test_abundance_with_location_helper('a(chebi:"CHEBI:25805"!"oxygen atom", location(GO:intracellular))')
+        self._test_abundance_with_location_helper(
+            'abundance(chebi:"CHEBI:25805"!"oxygen atom", location(GO:intracellular))'
+        )
+
+
 class TestGene(TestTokenParserBase):
     """2.1.4 http://openbel.org/language/web/version_2.0/bel_specification_version_2.0.html#XgeneA"""