Repository: datajoint/datajoint-python (language: python)

Issue:
datajoint python CLI
Examples:
```
vscode ➜ /workspaces/datajoint-python (dj-cli) $ dj
dj repl

>>> dj.config
{   'connection.charset': '',
    'connection.init_function': None,
    'database.host': 'fakeservices.datajoint.io',
    'database.password': 'simple',
    'database.port': 3306,
    'database.reconnect': True,
    'database.use_tls': None,
    'database.user': 'root',
    'display.limit': 12,
    'display.show_tuple_count': True,
    'display.width': 14,
    'enable_python_native_blobs': True,
    'fetch_format': 'array',
    'filepath_checksum_size_limit': None,
    'loglevel': 'INFO',
    'safemode': True}
>>> 
```

```
vscode ➜ /workspaces/datajoint-python (dj-cli) $ datajoint -u test_user -p test_password -h test_host
dj repl

>>> print(dj.config['database.user'],dj.config['database.password'],dj.config['database.host'])
test_user test_password test_host
>>> 
```

```
vscode ➜ /workspaces/datajoint-python (dj-cli) $ dj -s djtest_test1:test1 djtest_relational:relational
[2023-06-22 17:35:08,925][INFO]: Connecting root@fakeservices.datajoint.io:3306
[2023-06-22 17:35:08,930][INFO]: Connected root@fakeservices.datajoint.io:3306
dj repl

schema modules:

  - test1
  - relational

>>> test1.TTest()
*key    value    
+-----+ +-------+
0       0        
1       2        
2       4        
3       6        
4       8        
5       10       
6       12       
7       14       
8       16       
9       18       
 (Total: 10)

>>> 
```
datajoint python CLI
Examples:
```
vscode ➜ /workspaces/datajoint-python (dj-cli) $ dj
dj repl

>>> dj.config
{   'connection.charset': '',
    'connection.init_function': None,
    'database.host': 'fakeservices.datajoint.io',
    'database.password': 'simple',
    'database.port': 3306,
    'database.reconnect': True,
    'database.use_tls': None,
    'database.user': 'root',
    'display.limit': 12,
    'display.show_tuple_count': True,
    'display.width': 14,
    'enable_python_native_blobs': True,
    'fetch_format': 'array',
    'filepath_checksum_size_limit': None,
    'loglevel': 'INFO',
    'safemode': True}
>>> 
```

```
vscode ➜ /workspaces/datajoint-python (dj-cli) $ datajoint -u test_user -p test_password -h test_host
dj repl

>>> print(dj.config['database.user'],dj.config['database.password'],dj.config['database.host'])
test_user test_password test_host
>>> 
```

```
vscode ➜ /workspaces/datajoint-python (dj-cli) $ dj -s djtest_test1:test1 djtest_relational:relational
[2023-06-22 17:35:08,925][INFO]: Connecting root@fakeservices.datajoint.io:3306
[2023-06-22 17:35:08,930][INFO]: Connected root@fakeservices.datajoint.io:3306
dj repl

schema modules:

  - test1
  - relational

>>> test1.TTest()
*key    value    
+-----+ +-------+
0       0        
1       2        
2       4        
3       6        
4       8        
5       10       
6       12       
7       14       
8       16       
9       18       
 (Total: 10)

>>> 
```


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ce40fd7..8912cf37 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ### 0.14.3 -- TBD
 - Fixed - Added encapsulating double quotes to comply with [DOT language](https://graphviz.org/doc/info/lang.html) - PR [#1177](https://github.com/datajoint/datajoint-python/pull/1177)
+- Added - Datajoint python CLI ([#940](https://github.com/datajoint/datajoint-python/issues/940)) PR [#1095](https://github.com/datajoint/datajoint-python/pull/1095)
 - Added - Ability to set hidden attributes on a table - PR [#1091](https://github.com/datajoint/datajoint-python/pull/1091)
 
 ### 0.14.2 -- Aug 19, 2024
diff --git a/datajoint/__init__.py b/datajoint/__init__.py
index 096def46..59fac9e2 100644
--- a/datajoint/__init__.py
+++ b/datajoint/__init__.py
@@ -51,6 +51,7 @@
     "key",
     "key_hash",
     "logger",
+    "cli",
 ]
 
 from .logging import logger
@@ -70,6 +71,7 @@
 from .attribute_adapter import AttributeAdapter
 from . import errors
 from .errors import DataJointError
+from .cli import cli
 
 ERD = Di = Diagram  # Aliases for Diagram
 schema = Schema  # Aliases for Schema
diff --git a/datajoint/cli.py b/datajoint/cli.py
new file mode 100644
index 00000000..e2432fe6
--- /dev/null
+++ b/datajoint/cli.py
@@ -0,0 +1,77 @@
+import argparse
+from code import interact
+from collections import ChainMap
+import datajoint as dj
+
+
+def cli(args: list = None):
+    """
+    Console interface for DataJoint Python
+
+    :param args: List of arguments to be passed in, defaults to reading stdin
+    :type args: list, optional
+    """
+    parser = argparse.ArgumentParser(
+        prog="datajoint",
+        description="DataJoint console interface.",
+        conflict_handler="resolve",
+    )
+    parser.add_argument(
+        "-V", "--version", action="version", version=f"{dj.__name__} {dj.__version__}"
+    )
+    parser.add_argument(
+        "-u",
+        "--user",
+        type=str,
+        default=dj.config["database.user"],
+        required=False,
+        help="Datajoint username",
+    )
+    parser.add_argument(
+        "-p",
+        "--password",
+        type=str,
+        default=dj.config["database.password"],
+        required=False,
+        help="Datajoint password",
+    )
+    parser.add_argument(
+        "-h",
+        "--host",
+        type=str,
+        default=dj.config["database.host"],
+        required=False,
+        help="Datajoint host",
+    )
+    parser.add_argument(
+        "-s",
+        "--schemas",
+        nargs="+",
+        type=str,
+        required=False,
+        help="A list of virtual module mappings in `db:schema ...` format",
+    )
+    kwargs = vars(parser.parse_args(args))
+    mods = {}
+    if kwargs["user"]:
+        dj.config["database.user"] = kwargs["user"]
+    if kwargs["password"]:
+        dj.config["database.password"] = kwargs["password"]
+    if kwargs["host"]:
+        dj.config["database.host"] = kwargs["host"]
+    if kwargs["schemas"]:
+        for vm in kwargs["schemas"]:
+            d, m = vm.split(":")
+            mods[m] = dj.create_virtual_module(m, d)
+
+    banner = "dj repl\n"
+    if mods:
+        modstr = "\n".join("  - {}".format(m) for m in mods)
+        banner += "\nschema modules:\n\n" + modstr + "\n"
+    interact(banner, local=dict(ChainMap(mods, locals(), globals())))
+
+    raise SystemExit
+
+
+if __name__ == "__main__":
+    cli()
diff --git a/setup.py b/setup.py
index 90426068..e280038c 100644
--- a/setup.py
+++ b/setup.py
@@ -39,6 +39,9 @@
         "automated research workflows",
     ],
     packages=find_packages(exclude=["contrib", "docs", "tests*"]),
+    entry_points={
+        "console_scripts": ["dj=datajoint.cli:cli", "datajoint=datajoint.cli:cli"],
+    },
     install_requires=requirements,
     python_requires="~={}.{}".format(*min_py_version),
     setup_requires=["otumat"],  # maybe remove due to conflicts?


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 00000000..3f0fd00c
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,116 @@
+"""
+Collection of test cases to test the dj cli
+"""
+
+import json
+import ast
+import subprocess
+import pytest
+import datajoint as dj
+
+
+def test_cli_version(capsys):
+    with pytest.raises(SystemExit) as pytest_wrapped_e:
+        dj.cli(args=["-V"])
+    assert pytest_wrapped_e.type == SystemExit
+    assert pytest_wrapped_e.value.code == 0
+
+    captured_output = capsys.readouterr().out
+    assert captured_output == f"{dj.__name__} {dj.__version__}\n"
+
+
+def test_cli_help(capsys):
+    with pytest.raises(SystemExit) as pytest_wrapped_e:
+        dj.cli(args=["--help"])
+    assert pytest_wrapped_e.type == SystemExit
+    assert pytest_wrapped_e.value.code == 0
+
+    captured_output = capsys.readouterr().out
+    assert captured_output.strip()
+
+
+def test_cli_config():
+    process = subprocess.Popen(
+        ["dj"],
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        text=True,
+    )
+
+    process.stdin.write("dj.config\n")
+    process.stdin.flush()
+
+    stdout, stderr = process.communicate()
+    cleaned = stdout.strip(" >\t\n\r")
+    for key in ("database.user", "database.password", "database.host"):
+        assert key in cleaned, f"Key {key} not found in config from stdout: {cleaned}"
+
+
+def test_cli_args():
+    process = subprocess.Popen(
+        ["dj", "-utest_user", "-ptest_pass", "-htest_host"],
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        text=True,
+    )
+
+    process.stdin.write("dj.config['database.user']\n")
+    process.stdin.write("dj.config['database.password']\n")
+    process.stdin.write("dj.config['database.host']\n")
+    process.stdin.flush()
+
+    stdout, stderr = process.communicate()
+    assert "test_user" == stdout[5:14]
+    assert "test_pass" == stdout[21:30]
+    assert "test_host" == stdout[37:46]
+
+
+def test_cli_schemas(prefix, connection_root):
+    schema = dj.Schema(prefix + "_cli", locals(), connection=connection_root)
+
+    @schema
+    class IJ(dj.Lookup):
+        definition = """  # tests restrictions
+        i  : int
+        j  : int
+        """
+        contents = list(dict(i=i, j=j + 2) for i in range(3) for j in range(3))
+
+    process = subprocess.Popen(
+        ["dj", "-s", "djtest_cli:test_schema"],
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        text=True,
+    )
+
+    process.stdin.write("test_schema.__dict__['__name__']\n")
+    process.stdin.write("test_schema.__dict__['schema']\n")
+    process.stdin.write("test_schema.IJ.fetch(as_dict=True)\n")
+    process.stdin.flush()
+
+    stdout, stderr = process.communicate()
+    fetch_res = [
+        {"i": 0, "j": 2},
+        {"i": 0, "j": 3},
+        {"i": 0, "j": 4},
+        {"i": 1, "j": 2},
+        {"i": 1, "j": 3},
+        {"i": 1, "j": 4},
+        {"i": 2, "j": 2},
+        {"i": 2, "j": 3},
+        {"i": 2, "j": 4},
+    ]
+    assert (
+        "\
+dj repl\n\n\
+\
+schema modules:\n\n\
+  - test_schema"
+        == stderr[159:200]
+    )
+    assert "'test_schema'" == stdout[4:17]
+    assert "Schema `djtest_cli`" == stdout[22:41]
+    assert fetch_res == json.loads(stdout[47:209].replace("'", '"'))