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).

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

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
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"'

pip install -e .[dev]
parse_log_pytest
pytest
pytest pytest-cov pytest-xdist
apt-get update apt-get install -y gcc
3.8
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==25.3.0
bel-resources==0.0.3
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
click-plugins==1.1.1
coverage==7.6.1
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
execnet==2.1.1
greenlet==3.1.1
humanize==4.10.0
idna==3.10
importlib_resources==6.4.5
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
jsonschema==4.23.0
jsonschema-specifications==2023.12.1
more-itertools==10.5.0
multisplitby==0.0.1
networkx==3.1
numpy==1.24.4
packaging @ file:///croot/packaging_1720101850331/work
pandas==2.0.3
pkgutil_resolve_name==1.3.10
pluggy @ file:///tmp/build/80754af9/pluggy_1648042571233/work
-e git+https://github.com/pybel/pybel.git@7503e179c9f0525414e86dc897ec746ee15ef65d#egg=pybel
pyparsing==3.1.4
pytest @ file:///croot/pytest_1717793244625/work
pytest-cov==5.0.0
pytest-xdist==3.6.1
python-dateutil==2.9.0.post0
pytz==2025.2
referencing==0.35.1
requests==2.32.3
requests-file==2.1.0
rpds-py==0.20.1
six==1.17.0
SQLAlchemy==2.0.40
tabulate==0.9.0
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
tqdm==4.67.1
typing_extensions==4.13.0
tzdata==2025.2
urllib3==2.2.3
zipp==3.20.2

name: pybel
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py38h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.1=py38h06a4308_0
  - pip=24.2=py38h06a4308_0
  - pluggy=1.0.0=py38h06a4308_1
  - pytest=7.4.4=py38h06a4308_0
  - python=3.8.20=he870216_0
  - readline=8.2=h5eee18b_0
  - setuptools=75.1.0=py38h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py38h06a4308_0
  - wheel=0.44.0=py38h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==25.3.0
      - bel-resources==0.0.3
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - click==8.1.8
      - click-plugins==1.1.1
      - coverage==7.6.1
      - execnet==2.1.1
      - greenlet==3.1.1
      - humanize==4.10.0
      - idna==3.10
      - importlib-resources==6.4.5
      - jsonschema==4.23.0
      - jsonschema-specifications==2023.12.1
      - more-itertools==10.5.0
      - multisplitby==0.0.1
      - networkx==3.1
      - numpy==1.24.4
      - pandas==2.0.3
      - pkgutil-resolve-name==1.3.10
      - pyparsing==3.1.4
      - pytest-cov==5.0.0
      - pytest-xdist==3.6.1
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - referencing==0.35.1
      - requests==2.32.3
      - requests-file==2.1.0
      - rpds-py==0.20.1
      - six==1.17.0
      - sqlalchemy==2.0.40
      - tabulate==0.9.0
      - tqdm==4.67.1
      - typing-extensions==4.13.0
      - tzdata==2025.2
      - urllib3==2.2.3
      - zipp==3.20.2
prefix: /opt/conda/envs/pybel

tests/test_parse/test_parse_identifier.py::TestConceptParserRegex::test_valid tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_define_namespace_regex
tests/test_parse/test_parse_identifier.py::TestConceptEnumerated::test_invalid_1 tests/test_parse/test_parse_identifier.py::TestConceptEnumerated::test_invalid_2 tests/test_parse/test_parse_identifier.py::TestConceptEnumerated::test_invalid_3 tests/test_parse/test_parse_identifier.py::TestConceptEnumerated::test_invalid_4 tests/test_parse/test_parse_identifier.py::TestConceptEnumerated::test_valid_1 tests/test_parse/test_parse_identifier.py::TestConceptEnumerated::test_valid_2 tests/test_parse/test_parse_identifier.py::TestConceptParserDefault::test_not_in_defaultNs tests/test_parse/test_parse_identifier.py::TestConceptParserDefault::test_valid_1 tests/test_parse/test_parse_identifier.py::TestConceptParserDefault::test_valid_2 tests/test_parse/test_parse_identifier.py::TestConceptParserDefault::test_valid_3 tests/test_parse/test_parse_identifier.py::TestConceptParserLenient::test_invalid_1 tests/test_parse/test_parse_identifier.py::TestConceptParserLenient::test_invalid_2 tests/test_parse/test_parse_identifier.py::TestConceptParserLenient::test_not_invalid_3 tests/test_parse/test_parse_identifier.py::TestConceptParserLenient::test_not_invalid_4 tests/test_parse/test_parse_identifier.py::TestConceptParserLenient::test_valid_1 tests/test_parse/test_parse_identifier.py::TestConceptParserLenient::test_valid_2 tests/test_parse/test_parse_identifier.py::TestConceptParserRegex::test_invalid tests/test_parse/test_parse_identifier.py::TestConceptParserRegex::test_invalid_obo tests/test_parse/test_parse_identifier.py::TestConceptParserRegex::test_valid_obo tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_annotation_name_persistience_1 tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_annotation_name_persistience_2 tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_control_compound tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_document_metadata_exception tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_namespace_name_persistience tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_not_semantic_version tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_parse_annotation_pattern tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_parse_annotation_url_file tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_parse_document tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_parse_namespace_url_file tests/test_parse/test_parse_metadata.py::TestParseMetadata::test_underscore
src/pybel/parser/parse_metadata.py src/pybel/parser/parse_concept.py src/pybel/parser/utils.py
src/pybel/parser/parse_metadata.py src/pybel/parser/parse_concept.py src/pybel/parser/utils.py