Repository: pybel/pybel

Issue:
Can't parse official ec-code keyword
1. dash in keyword is a problem
2. dots need to be quoted but they shouldn't have to be

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/460?src=pr&el=h1) Report
> Merging [#460](https://codecov.io/gh/pybel/pybel/pull/460?src=pr&el=desc) into [master](https://codecov.io/gh/pybel/pybel/commit/ddb346bbdbdb2f04960209d9dc3a4a972e0e29d3&el=desc) will **increase** coverage by `0.64%`.
> The diff coverage is `100.00%`.

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

```diff
@@            Coverage Diff             @@
##           master     #460      +/-   ##
==========================================
+ Coverage   78.22%   78.86%   +0.64%     
==========================================
  Files         176      176              
  Lines        9404     9741     +337     
  Branches     1362     1407      +45     
==========================================
+ Hits         7356     7682     +326     
- Misses       1761     1769       +8     
- Partials      287      290       +3     
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/460?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/parser/parse\_concept.py](https://codecov.io/gh/pybel/pybel/pull/460/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3BhcnNlci9wYXJzZV9jb25jZXB0LnB5) | `96.05% <100.00%> (ø)` | |
| [src/pybel/parser/parse\_metadata.py](https://codecov.io/gh/pybel/pybel/pull/460/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3BhcnNlci9wYXJzZV9tZXRhZGF0YS5weQ==) | `94.11% <100.00%> (ø)` | |
| [src/pybel/parser/utils.py](https://codecov.io/gh/pybel/pybel/pull/460/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3BhcnNlci91dGlscy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pybel/dsl/node\_classes.py](https://codecov.io/gh/pybel/pybel/pull/460/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2RzbC9ub2RlX2NsYXNzZXMucHk=) | `93.34% <0.00%> (+0.71%)` | :arrow_up: |
| [src/pybel/io/triples/converters.py](https://codecov.io/gh/pybel/pybel/pull/460/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL3RyaXBsZXMvY29udmVydGVycy5weQ==) | `95.78% <0.00%> (+2.24%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/460?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/460?src=pr&el=footer). Last update [ddb346b...3b778ee](https://codecov.io/gh/pybel/pybel/pull/460?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 dc0d1cee..4ef7a15d 100644
--- a/src/pybel/parser/parse_concept.py
+++ b/src/pybel/parser/parse_concept.py
@@ -11,7 +11,7 @@ from pyparsing import ParseResults, Suppress
 
 from .baseparser import BaseParser
 from .constants import NamespaceTermEncodingMapping
-from .utils import quote, word
+from .utils import ns, quote
 from ..constants import DIRTY, IDENTIFIER, NAME, NAMESPACE
 from ..exceptions import (
     MissingDefaultNameWarning, MissingNamespaceNameWarning, MissingNamespaceRegexWarning, NakedNameWarning,
@@ -48,13 +48,13 @@ class ConceptParser(BaseParser):
         :param allow_naked_names: If true, turn off naked namespace failures
         """
         self.identifier_fqualified = (
-            word(NAMESPACE)
+            ns(NAMESPACE)
             + Suppress(':')
-            + (word | quote)(IDENTIFIER)
+            + (ns | quote)(IDENTIFIER)
             + Suppress('!')
-            + (word | quote)(NAME)
+            + (ns | quote)(NAME)
         )
-        self.identifier_qualified = word(NAMESPACE) + Suppress(':') + (word | quote)(NAME)
+        self.identifier_qualified = ns(NAMESPACE) + Suppress(':') + (ns | quote)(NAME)
 
         if namespace_to_term_to_encoding is not None:
             self.namespace_to_name_to_encoding = defaultdict(dict)
@@ -81,7 +81,7 @@ 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_bare = (word | quote)(NAME)
+        self.identifier_bare = (ns | quote)(NAME)
         self.identifier_bare.setParseAction(
             self.handle_namespace_default if self.default_namespace else
             self.handle_namespace_lenient if self.allow_naked_names else
diff --git a/src/pybel/parser/parse_metadata.py b/src/pybel/parser/parse_metadata.py
index d6588a50..686b4c81 100644
--- a/src/pybel/parser/parse_metadata.py
+++ b/src/pybel/parser/parse_metadata.py
@@ -6,11 +6,11 @@ import logging
 import re
 from typing import Mapping, Optional, Pattern, Set
 
-from pyparsing import And, MatchFirst, ParseResults, Suppress, Word, pyparsing_common as ppc
+from pyparsing import And, MatchFirst, ParseResults, Suppress, Word
 
 from .baseparser import BaseParser
 from .constants import NamespaceTermEncodingMapping
-from .utils import delimited_quoted_list, qid, quote, word
+from .utils import delimited_quoted_list, ns, qid, quote, word
 from ..constants import (
     BEL_KEYWORD_ANNOTATION, BEL_KEYWORD_AS, BEL_KEYWORD_DEFINE, BEL_KEYWORD_DOCUMENT, BEL_KEYWORD_LIST,
     BEL_KEYWORD_NAMESPACE, BEL_KEYWORD_PATTERN, BEL_KEYWORD_SET, BEL_KEYWORD_URL, DOCUMENT_KEYS, METADATA_VERSION,
@@ -117,11 +117,11 @@ class MetadataParser(BaseParser):
             qid('value'),
         ])
 
-        namespace_tag = And([define_tag, Suppress(BEL_KEYWORD_NAMESPACE), ppc.identifier('name'), as_tag])
+        namespace_tag = And([define_tag, Suppress(BEL_KEYWORD_NAMESPACE), ns('name'), as_tag])
         self.namespace_url = And([namespace_tag, url_tag, quote('url')])
         self.namespace_pattern = And([namespace_tag, Suppress(BEL_KEYWORD_PATTERN), quote('value')])
 
-        annotation_tag = And([define_tag, Suppress(BEL_KEYWORD_ANNOTATION), ppc.identifier('name'), as_tag])
+        annotation_tag = And([define_tag, Suppress(BEL_KEYWORD_ANNOTATION), ns('name'), as_tag])
         self.annotation_url = And([annotation_tag, url_tag, quote('url')])
         self.annotation_list = And([annotation_tag, list_tag, delimited_quoted_list('values')])
         self.annotation_pattern = And([annotation_tag, Suppress(BEL_KEYWORD_PATTERN), quote('value')])
diff --git a/src/pybel/parser/utils.py b/src/pybel/parser/utils.py
index 2f046420..b7bf7473 100644
--- a/src/pybel/parser/utils.py
+++ b/src/pybel/parser/utils.py
@@ -37,6 +37,7 @@ LP = Suppress('(') + W
 RP = W + Suppress(')')
 
 word = Word(alphanums)
+ns = Word(alphanums + '_-.')
 identifier = Word(alphanums + '_')
 quote = dblQuotedString().setParseAction(removeQuotes)
 qid = quote | identifier


Test patch:
diff --git a/tests/test_parse/test_parse_identifier.py b/tests/test_parse/test_parse_identifier.py
index cd180863..a5fc6e33 100644
--- a/tests/test_parse/test_parse_identifier.py
+++ b/tests/test_parse/test_parse_identifier.py
@@ -175,19 +175,25 @@ class TestConceptParserRegex(unittest.TestCase):
     """Tests for regular expression parsing"""
 
     def setUp(self) -> None:
-        self.parser = ConceptParser(namespace_to_pattern={'hgnc': re.compile(r'\d+')})
+        self.parser = ConceptParser(namespace_to_pattern={
+            'hgnc': re.compile(r'\d+'),
+            'ec-code': re.compile(r'.+'),
+        })
         self.assertEqual({}, self.parser.namespace_to_identifier_to_encoding)
         self.assertEqual({}, self.parser.namespace_to_name_to_encoding)
 
     def test_valid(self):
-        s = 'hgnc:391'
-        result = self.parser.parseString(s)
-
-        self.assertIn('namespace', result)
-        self.assertIn('name', result)
-        self.assertNotIn('identifier', result)
-        self.assertEqual('hgnc', result['namespace'])
-        self.assertEqual('391', result['name'])
+        for curie, namespace, name in [
+            ('hgnc:391', 'hgnc', '391'),
+            ('ec-code:1.1.1.27', 'ec-code', '1.1.1.27'),
+        ]:
+            with self.subTest(curie=curie):
+                result = self.parser.parseString(curie)
+                self.assertIn('namespace', result)
+                self.assertIn('name', result)
+                self.assertNotIn('identifier', result)
+                self.assertEqual(namespace, result['namespace'])
+                self.assertEqual(name, result['name'])
 
     def test_invalid(self):
         """Test invalid BEL term."""
diff --git a/tests/test_parse/test_parse_metadata.py b/tests/test_parse/test_parse_metadata.py
index af71c512..f1192e34 100644
--- a/tests/test_parse/test_parse_metadata.py
+++ b/tests/test_parse/test_parse_metadata.py
@@ -178,12 +178,15 @@ class TestParseMetadata(FleetingTemporaryCacheMixin):
         self.assertEqual(re.compile(r'\w+'), self.parser.annotation_to_pattern['Test'])
 
     def test_define_namespace_regex(self):
-        s = 'DEFINE NAMESPACE dbSNP AS PATTERN "rs[0-9]*"'
-        self.parser.parseString(s)
-
-        self.assertNotIn('dbSNP', self.parser.namespace_to_term_to_encoding)
-        self.assertIn('dbSNP', self.parser.namespace_to_pattern)
-        self.assertEqual(re.compile('rs[0-9]*'), self.parser.namespace_to_pattern['dbSNP'])
+        for s, namespace, regex in [
+            ('DEFINE NAMESPACE dbSNP AS PATTERN "rs[0-9]*"', 'dbSNP', re.compile(r'rs[0-9]*')),
+            ('DEFINE NAMESPACE ec-code AS PATTERN ".*"', 'ec-code', re.compile(r'.*')),
+        ]:
+            with self.subTest(namespace=namespace):
+                self.parser.parseString(s)
+                self.assertNotIn(namespace, self.parser.namespace_to_term_to_encoding)
+                self.assertIn(namespace, self.parser.namespace_to_pattern)
+                self.assertEqual(regex, self.parser.namespace_to_pattern[namespace])
 
     def test_not_semantic_version(self):
         s = 'SET DOCUMENT Version = "1.0"'