Repository: frictionlessdata/frictionless-py

Issue:
Ability to pass dialect parameters via CLI commands
# Overview

Is it possible to provide dialect options via command line?

For example:

    frictionless extract --sheet Sheet2 data.ods

I see, that ODS dialect does support `sheet` parameter, but could not find how to pass it via command line.

---

Please preserve this line to notify @roll (lead of this repository)


Discussion:
roll: Thanks, @alexkreidler, I'll review tomorrow

BTW, please fix the linting `make format`
alexkreidler: Tests are all passing for the extract command extract. When I add the same code to validate it breaks tests like this: (I'll also push the code so CI can run the tests)

```
FAILED tests/program/test_validate.py::test_program_validate_skip_errors - AssertionError: assert {'errors': []...d': True, ...} == {'errors': []...d...
FAILED tests/program/test_validate.py::test_program_validate_limit_errors - AssertionError: assert {'errors': []...d': True, ...} == {'errors': []......
FAILED tests/program/test_validate.py::test_program_validate_yaml - AssertionError: assert {'errors': []...d': True, ...} == {'errors': []...d': True...
FAILED tests/program/test_validate.py::test_program_validate_json - AssertionError: assert {'errors': []...d': True, ...} == {'errors': []...d': True...
============================================= 21 failed, 1373 passed, 28 skipped, 25 deselected in 25.30s ====
```

Patch:
diff --git a/.gitignore b/.gitignore
index 09e448db..627078b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,8 @@ var/
 .installed.cfg
 *.egg
 
+pip-wheel-metadata/
+
 # PyInstaller
 #  Usually these files are written by a python script from a template
 #  before PyInstaller builds the exe, so as to inject date/other infos into it.
@@ -86,3 +88,4 @@ datapackage
 !docs/build
 .google.json
 tmp
+.vscode/
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f3c88907..3783d063 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -71,6 +71,8 @@ make install
 alias "frictionless=python -m frictionless"
 ```
 
+Note: You may need to run `sudo apt-get install postgresql libpq-dev` on a Debian-based system, because the python Postgres module depends on some postgres CLI tools.
+
 Then you can run various make commands:
 - `make docs` - build the docs
 - `make format` - format source code
diff --git a/data/sheet-extract-dialect.json b/data/sheet-extract-dialect.json
new file mode 100644
index 00000000..f276ff96
--- /dev/null
+++ b/data/sheet-extract-dialect.json
@@ -0,0 +1,1 @@
+{"sheet": "Sheet2"}
diff --git a/frictionless/program/common.py b/frictionless/program/common.py
index 7d9f54bf..75488ae5 100644
--- a/frictionless/program/common.py
+++ b/frictionless/program/common.py
@@ -17,7 +17,7 @@ type = Option(
 
 path = Option(
     default=None,
-    help="Specify the data path explicetly (e.g. you need to use it if your data is JSON)",
+    help="Specify the data path explicitly (e.g. you need to use it if your data is JSON)",
 )
 
 scheme = Option(
@@ -54,16 +54,39 @@ compression = Option(
 
 control = Option(
     default=None,
-    help="Specify a path to a control",
+    help="An inline JSON object or a path to a JSON file that provides the control (configuration for the data Loader)",
 )
 
 # Dialect
 
 dialect = Option(
     default=None,
-    help="Specify a path to a dialect",
+    help="An inline JSON object or a path to a JSON file that provides the dialect (configuration for the parser)",
 )
 
+
+sheet = Option(
+    default=None,
+    help="The sheet to use from the input data (only with XLS and ODS files/plugins)",
+)
+
+table = Option(
+    default=None,
+    help="The table to use from the SQL database (SQL plugin)",
+)
+
+keys = Option(
+    default=None,
+    help="The keys to use as column names for the Inline or JSON data plugins",
+)
+
+
+keyed = Option(
+    default=None,
+    help="Whether the input data is keyed for the Inline or JSON data plugins",
+)
+
+
 # Layout
 
 header_rows = Option(
diff --git a/frictionless/program/extract.py b/frictionless/program/extract.py
index 52895465..d2540210 100644
--- a/frictionless/program/extract.py
+++ b/frictionless/program/extract.py
@@ -10,6 +10,7 @@ from ..layout import Layout
 from .main import program
 from .. import helpers
 from . import common
+from frictionless import Dialect
 
 
 @program.command(name="extract")
@@ -29,6 +30,10 @@ def program_extract(
     control: str = common.control,
     # Dialect
     dialect: str = common.dialect,
+    sheet: str = common.sheet,
+    table: str = common.table,
+    keys: List[str] = common.keys,
+    keyed: bool = common.keyed,
     # Layout
     header_rows: str = common.header_rows,
     header_join: str = common.header_join,
@@ -82,6 +87,21 @@ def program_extract(
     source = list(source) if len(source) > 1 else (source[0] if source else None)
     control = helpers.parse_json_string(control)
     dialect = helpers.parse_json_string(dialect)
+
+    dialect = Dialect(dialect)
+
+    if sheet:
+        dialect["sheet"] = sheet
+    if table:
+        dialect["table"] = table
+    if keys:
+        dialect["keys"] = list(keys)
+    if keyed:
+        dialect["keyed"] = keyed
+
+    if len(dialect.to_dict()) < 1:
+        dialect = None
+
     header_rows = helpers.parse_csv_string(header_rows, convert=int)
     pick_fields = helpers.parse_csv_string(pick_fields, convert=int, fallback=True)
     skip_fields = helpers.parse_csv_string(skip_fields, convert=int, fallback=True)
diff --git a/frictionless/program/validate.py b/frictionless/program/validate.py
index 01532f5f..0ce01cc8 100644
--- a/frictionless/program/validate.py
+++ b/frictionless/program/validate.py
@@ -8,6 +8,7 @@ from ..layout import Layout
 from .main import program
 from .. import helpers
 from . import common
+from frictionless import Dialect
 
 
 @program.command(name="validate")
@@ -27,6 +28,10 @@ def program_validate(
     control: str = common.control,
     # Dialect
     dialect: str = common.dialect,
+    sheet: str = common.sheet,
+    table: str = common.table,
+    keys: List[str] = common.keys,
+    keyed: bool = common.keyed,
     # Layout
     header_rows: str = common.header_rows,
     header_join: str = common.header_join,
@@ -89,6 +94,21 @@ def program_validate(
     source = list(source) if len(source) > 1 else (source[0] if source else None)
     control = helpers.parse_json_string(control)
     dialect = helpers.parse_json_string(dialect)
+
+    dialect = Dialect(dialect)
+
+    if sheet:
+        dialect["sheet"] = sheet
+    if table:
+        dialect["table"] = table
+    if keys:
+        dialect["keys"] = list(keys)
+    if keyed:
+        dialect["keyed"] = keyed
+
+    if len(dialect.to_dict()) < 1:
+        dialect = None
+
     header_rows = helpers.parse_csv_string(header_rows, convert=int)
     pick_fields = helpers.parse_csv_string(pick_fields, convert=int, fallback=True)
     skip_fields = helpers.parse_csv_string(skip_fields, convert=int, fallback=True)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index b5312b3e..09af0a48 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -70,6 +70,11 @@ def database_url(sqlite_url):
     conn = engine.connect()
     conn.execute("CREATE TABLE 'table' (id INTEGER PRIMARY KEY, name TEXT)")
     conn.execute("INSERT INTO 'table' VALUES (1, 'english'), (2, '中国人')")
+
+    conn.execute(
+        "CREATE TABLE 'fruits' (uid INTEGER PRIMARY KEY, fruit_name TEXT, calories INTEGER)"
+    )
+    conn.execute("INSERT INTO 'fruits' VALUES (1, 'Apples', 200), (2, 'Oranges中国人', 350)")
     yield sqlite_url
     conn.close()
 
diff --git a/tests/program/test_extract.py b/tests/program/test_extract.py
index 8752b752..76e7c33a 100644
--- a/tests/program/test_extract.py
+++ b/tests/program/test_extract.py
@@ -1,7 +1,8 @@
+from frictionless.plugins.sql import SqlDialect
 import json
 import yaml
 from typer.testing import CliRunner
-from frictionless import program, extract, Detector, helpers
+from frictionless import program, extract, Detector, helpers, Resource
 
 runner = CliRunner()
 IS_UNIX = not helpers.is_platform("windows")
@@ -164,3 +165,42 @@ def test_program_extract_csv():
     if IS_UNIX:
         with open("data/table.csv") as file:
             assert result.stdout == file.read()
+
+
+def test_program_extract_dialect_sheet_option():
+    file = "data/sheet2.xls"
+    sheet = "Sheet2"
+    result = runner.invoke(program, f"extract {file} --sheet {sheet} --json")
+    assert result.exit_code == 0
+    assert json.loads(result.stdout) == extract(file, dialect={"sheet": sheet})
+
+
+def test_program_extract_dialect_table_option_sql(database_url):
+    table = "fruits"
+    result = runner.invoke(program, f"extract {database_url} --table {table} --json")
+    assert result.exit_code == 0
+
+    dialect = SqlDialect(table=table)
+    with Resource(database_url, dialect=dialect) as resource:
+        assert json.loads(result.stdout) == extract(
+            resource,
+        )
+
+
+def test_program_extract_dialect_keyed_option():
+    file = "data/table.keyed.json"
+    keyed = True
+    result = runner.invoke(program, f"extract --path {file} --keyed {keyed} --json")
+    assert result.exit_code == 0
+    assert json.loads(result.stdout) == extract(path=file, dialect={"keyed": keyed})
+
+
+def test_program_extract_dialect_keys_option():
+    file = "data/table.keyed.json"
+    keys = ["name", "id"]
+    delim = " --keys "
+    result = runner.invoke(
+        program, f"extract --path {file} --keys {delim.join(keys)} --json"
+    )
+    assert result.exit_code == 0
+    assert json.loads(result.stdout) == extract(path=file, dialect={"keys": keys})