Repository: lexibank/pylexibank

Issue:
Support multiple profiles
Do we have a standard way of supporting multiple orthographic profiles? Or should we try to always use a single file, even when the code and the profile become more complex?

My question concerns mostly WOLD, with 41 very different profiles (one per language). I couldn't find a non-hacky way (involving lambdas and the like) to tell pylexibank "use the language-id of each row as a tokenizer-id".

The `_tokenizer` in Dataset does accept a column name via `**kwargs` (https://github.com/lexibank/pylexibank/blob/021f37481d37b472715033033773543273ed301f/src/pylexibank/dataset.py#L152), but there are conflicts when a grapheme is missing from one or more profiles (e.g., in some language "ch" is just "c"+"h", and even redundantly listing all missing graphemes in all languages is not enough, as it can lead to inconsistencies, like consuming characters in the stream that are not supposed to be consumed).

Besides, it doesn't seem that LexibankWriter allows me to pass such information (cf. https://github.com/lexibank/pylexibank/blob/021f37481d37b472715033033773543273ed301f/src/pylexibank/cldf.py#L93), as it only takes `item` (the **kw passed to `add_form`) and `string` (the value to be segmented). 

Currently, while refining WOLD, I'm manually loading all profiles in a `tokenizer` dictionary, inside `cmd_makecldf()`, and calling it directly:

```python
for row in progressbar(lexemes_rows):
    args.writer.add_form_with_segments(
        Language_ID=row["Language_ID"],
        Parameter_ID=row["Parameter_ID"],
        Form=row["Form"],
        Value=row["Form"],
        Segments=tokenizer[row["Language_ID"]]({}, row["Form"]),
        (...)
```

Note that this is **not** urgent: CLICS3 won't depend on this (we don't need segmented data, and I'll get back to it after we resubmit).

Discussion:
xrotwang: @tresoldi I added tests, so backwards compat should be provided for. Merging now.
codecov-io: # [Codecov](https://codecov.io/gh/lexibank/pylexibank/pull/152?src=pr&el=h1) Report
> Merging [#152](https://codecov.io/gh/lexibank/pylexibank/pull/152?src=pr&el=desc) into [master](https://codecov.io/gh/lexibank/pylexibank/commit/7a360022dbae0311095feb903bb6924e1b659c3a?src=pr&el=desc) will **increase** coverage by `0.02%`.
> The diff coverage is `100%`.

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

```diff
@@            Coverage Diff             @@
##           master     #152      +/-   ##
==========================================
+ Coverage   97.34%   97.36%   +0.02%     
==========================================
  Files          26       26              
  Lines        1467     1481      +14     
==========================================
+ Hits         1428     1442      +14     
  Misses         39       39
```


| [Impacted Files](https://codecov.io/gh/lexibank/pylexibank/pull/152?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pylexibank/dataset.py](https://codecov.io/gh/lexibank/pylexibank/pull/152/diff?src=pr&el=tree#diff-c3JjL3B5bGV4aWJhbmsvZGF0YXNldC5weQ==) | `98.13% <100%> (+0.17%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/lexibank/pylexibank/pull/152?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/lexibank/pylexibank/pull/152?src=pr&el=footer). Last update [7a36002...070af60](https://codecov.io/gh/lexibank/pylexibank/pull/152?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/pylexibank/dataset.py b/src/pylexibank/dataset.py
index c2c596b..078b376 100644
--- a/src/pylexibank/dataset.py
+++ b/src/pylexibank/dataset.py
@@ -139,19 +139,37 @@ class Dataset(BaseDataset):
         - `kw` may be used to pass any context info to the tokenizer, when called
           explicitly.
         """
-        profile = self.dir / 'etc' / 'orthography.tsv'
-        if profile.exists():
-            profile = Profile.from_file(str(profile), form='NFC')
-            default_spec = list(next(iter(profile.graphemes.values())).keys())
+        def get_tokenizer(profile_):
+            profile_ = Profile.from_file(str(profile_), form='NFC')
+            default_spec = list(next(iter(profile_.graphemes.values())).keys())
             for grapheme in ['^', '$']:
-                if grapheme not in profile.graphemes:
-                    profile.graphemes[grapheme] = {k: None for k in default_spec}
-            profile.tree = Tree(list(profile.graphemes.keys()))
-            tokenizer = Tokenizer(profile=profile, errors_replace=lambda c: '<{0}>'.format(c))
+                if grapheme not in profile_.graphemes:
+                    profile_.graphemes[grapheme] = {k: None for k in default_spec}
+            profile_.tree = Tree(list(profile_.graphemes.keys()))
+            return Tokenizer(profile=profile_, errors_replace=lambda c: '<{0}>'.format(c))
+
+        tokenizers = {}
+        profile = self.etc_dir / 'orthography.tsv'
+        profile_dir = self.etc_dir / 'orthography'
+        if profile.exists():
+            tokenizers[None] = get_tokenizer(profile)
+        if profile_dir.exists() and profile_dir.is_dir():
+            for profile in profile_dir.glob('*.tsv'):
+                tokenizers[profile.stem] = get_tokenizer(profile)
 
+        if tokenizers:
             def _tokenizer(item, string, **kw):
                 kw.setdefault("column", "IPA")
                 kw.setdefault("separator", " + ")
+                profile = kw.pop('profile', None)
+                if profile:
+                    tokenizer = tokenizers[profile]
+                elif isinstance(item, dict) \
+                        and 'Language_ID' in item \
+                        and item['Language_ID'] in tokenizers:
+                    tokenizer = tokenizers[item['Language_ID']]
+                else:
+                    tokenizer = tokenizers[None]
                 return tokenizer(unicodedata.normalize('NFC', '^' + string + '$'), **kw).split()
             return _tokenizer
 


Test patch:
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
index 4d9f66b..a656b42 100644
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -1,12 +1,11 @@
 import sys
 import json
-import argparse
 import importlib
+import pathlib
 
 import pytest
 from clldutils.path import sys_path
 from cldfbench.dataset import NOOP
-from pyclts import TranscriptionSystem
 
 from pylexibank import Lexeme, Dataset
 from pylexibank.dataset import Unmapped
@@ -44,6 +43,26 @@ def test_invalid_dataset():
         Test()
 
 
+def test_Dataset_tokenizer(tmpdir):
+    etc = pathlib.Path(str(tmpdir)).joinpath('etc')
+    etc.mkdir()
+    orth_dir = etc.joinpath('orthography')
+    orth_dir.mkdir()
+    orth_dir.joinpath('l1.tsv').write_text('Grapheme\tIPA\na\tb')
+
+    class DS(Dataset):
+        id = '1'
+        dir = etc.parent
+
+    ds = DS()
+    assert ds.tokenizer({}, 'a', profile='l1') == ['b']
+    assert ds.tokenizer({'Language_ID': 'l1'}, 'a') == ['b']
+
+    etc.joinpath('orthography.tsv').write_text('Grapheme\tIPA\na\tc')
+    ds = DS()
+    assert ds.tokenizer({}, 'a') == ['c']
+
+
 def test_BaseDataset(mocker, repos):
     class TestDataset(Dataset):
         dir = repos / 'datasets' / 'test_dataset'