Repository: pydicom/pydicom

Issue:
cli: support specification of the numeric tag
**Is your feature request related to a problem? Please describe.**

```shell
❯ pydicom show ~/proj/heudiconv/dicoms/dcm_qa_xa30/In/7_func-bold_task-fa_run-1/0001_1.3.12.2.1107.5.2.43.67093.2022071112090640678703211.dcm::InstitutionName
Indiana University Psychology Department
❯ pydicom show ~/proj/heudiconv/dicoms/dcm_qa_xa30/In/7_func-bold_task-fa_run-1/0001_1.3.12.2.1107.5.2.43.67093.2022071112090640678703211.dcm | grep Institution
(0008, 0080) Institution Name                    LO: 'Indiana University Psychology Department'
(0008, 0081) Institution Address                 ST: 'East 10th Street 1101,Bloomington,IN,US,47405'
```

but impossible to query via e.g.

```shell
❯ pydicom show ~/proj/heudiconv/dicoms/dcm_qa_xa30/In/7_func-bold_task-fa_run-1/0001_1.3.12.2.1107.5.2.43.67093.2022071112090640678703211.dcm::"(0008, 0080)"
usage: pydicom show [-h] [-x] [-t] [-q] filespec
pydicom show: error: argument filespec: Component '(0008, 0080)' is not valid syntax for a data element, sequence, or sequence item
```

Hints from issue comments:
@darcymason: That's an interesting idea, and I guess necessary for private tags or others not in the pydicom dictionary.  I had a quick look at the code and I think it could be done without too much trouble.@darcymason: Closed by #2188.

Solution patch:
diff --git a/doc/guides/cli/cli_codify.rst b/doc/guides/cli/cli_codify.rst
index fe4a3a595d..804eb2d9e4 100644
--- a/doc/guides/cli/cli_codify.rst
+++ b/doc/guides/cli/cli_codify.rst
@@ -60,12 +60,11 @@ was specified. To see the available command options, use the ``help`` command:
     Read a DICOM file and produce the *pydicom* (Python) code which can create that file
 
     positional arguments:
-    filespec              File specification, in format [pydicom::]filename[::element]. If `pydicom::`
-                            prefix is used, then use the pydicom test file with that name. If `element`
-                            is given, use only that data element within the file. Examples:
-                            path/to/your_file.dcm, your_file.dcm::StudyDate,
-                            pydicom::rtplan.dcm::BeamSequence[0],
-                            yourplan.dcm::BeamSequence[0].BeamNumber
+    filespec           File specification, in format [pydicom::]filename[::element]. If `pydicom::` prefix is present,
+                        then use the pydicom test file with that name. If `element` is given, use only that data element
+                        within the file. Examples: path/to/your_file.dcm, your_file.dcm::StudyDate,
+                        your_file.dcm::(0001,0001), pydicom::rtplan.dcm::BeamSequence[0],
+                        yourplan.dcm::BeamSequence[0].BeamNumber, pydicom::rtplan.dcm::(300A,00B0)[0].(300A,00B6)
     outfile               Filename to write python code to. If not specified, code is written to
                             stdout
 
diff --git a/doc/guides/cli/cli_intro.rst b/doc/guides/cli/cli_intro.rst
index 69c161c9a2..dd5463308a 100644
--- a/doc/guides/cli/cli_intro.rst
+++ b/doc/guides/cli/cli_intro.rst
@@ -69,11 +69,12 @@ by typing ``pydicom help [subcommand]``.  For example:
     Display all or part of a DICOM file
 
     positional arguments:
-    filespec              File specification, in format [pydicom::]filename[::element]. If `pydicom::`
-                            prefix is used, then show the pydicom test file with that name. If `element`
-                            is given, use only that data element within the file. Examples:
-                            path/to/your_file.dcm, your_file.dcm::StudyDate,
-                            pydicom::rtplan.dcm::BeamSequence[0], yourplan.dcm::BeamSequence[0].BeamNumber
+    filespec           File specification, in format [pydicom::]filename[::element]. If `pydicom::` prefix is present,
+                        then use the pydicom test file with that name. If `element` is given, use only that data element
+                        within the file. Examples: path/to/your_file.dcm, your_file.dcm::StudyDate,
+                        your_file.dcm::(0001,0001), pydicom::rtplan.dcm::BeamSequence[0],
+                        yourplan.dcm::BeamSequence[0].BeamNumber, pydicom::rtplan.dcm::(300A,00B0)[0].(300A,00B6)
+
 
     optional arguments:
     -h, --help            show this help message and exit
@@ -91,9 +92,9 @@ The ``pydicom`` command should automatically be available after you
 path or environment variables.
 
 If you are helping develop *pydicom* code, and are using git clones,
-you will have to ``pip install -e .`` or ``python setup.py develop`` from
+you will have to ``pip install -e .`` from
 the `pydicom` repository root. This has to be repeated for any changes to
-`setup.py` (e.g. to add a new subcommand).
+`pyproject.toml` (e.g. to add a new subcommand).
 
 If you are developing subcommands within your own package, you will need to
 reinstall your package similar to the above as you add entry points.
diff --git a/doc/guides/cli/cli_show.rst b/doc/guides/cli/cli_show.rst
index 8a18195004..9e790ca51e 100644
--- a/doc/guides/cli/cli_show.rst
+++ b/doc/guides/cli/cli_show.rst
@@ -19,11 +19,11 @@ or ``pydicom show -h``.
     Display all or part of a DICOM file
 
     positional arguments:
-    filespec              File specification, in format [pydicom::]filename[::element]. If `pydicom::`
-                            prefix is present, then use the pydicom test file with that name. If `element`
-                            is given, use only that data element within the file. Examples:
-                            path/to/your_file.dcm, your_file.dcm::StudyDate,
-                            pydicom::rtplan.dcm::BeamSequence[0], yourplan.dcm::BeamSequence[0].BeamNumber
+    filespec           File specification, in format [pydicom::]filename[::element]. If `pydicom::` prefix is present,
+                        then use the pydicom test file with that name. If `element` is given, use only that data element
+                        within the file. Examples: path/to/your_file.dcm, your_file.dcm::StudyDate,
+                        your_file.dcm::(0001,0001), pydicom::rtplan.dcm::BeamSequence[0],
+                        yourplan.dcm::BeamSequence[0].BeamNumber, pydicom::rtplan.dcm::(300A,00B0)[0].(300A,00B6)
 
     optional arguments:
     -h, --help            show this help message and exit
@@ -88,11 +88,25 @@ using the usual pydicom keyword notation:
     (300c, 0006) Referenced Beam Number              IS: "1"
     ---------]
 
+You can also use a tag number in format (group,elem) anywhere a DICOM keyword can be used:
+
+.. code-block:: console
+
+    $ pydicom show pydicom::ct_small.dcm::(0043,1013)
+    [107, 21, 4, 2, 20]
+
+    $ pydicom show pydicom::rtplan.dcm::(300A,00B0)[0].(300a,0111)
+    [(300A,0112) Control Point Index                 IS: '0'
+    (300A,0114) Nominal Beam Energy                 DS: '6.00000000000000'
+    ...
+
 The ``-q`` quiet argument shows a minimal version of some of the information in the
 file, using just the DICOM keyword and value (not showing the tag numbers
-and VR). The example below shows the quiet mode with an image slice::
+and VR). The example below shows the quiet mode with an image slice:
 
-    pydicom show -q pydicom::ct_small.dcm
+.. code-block:: console
+
+    $ pydicom show -q pydicom::ct_small.dcm
 
     SOPClassUID: CT Image Storage
     PatientName: CompressedSamples^CT1
@@ -107,9 +121,11 @@ and VR). The example below shows the quiet mode with an image slice::
     Columns: 128
     SliceLocation: -77.2040634155
 
-And the following example shows an RT Plan in quiet mode::
+And the following example shows an RT Plan in quiet mode:
+
+.. code-block:: console
 
-    pydicom show -q pydicom::rtplan.dcm
+    $ pydicom show -q pydicom::rtplan.dcm
 
     SOPClassUID: RT Plan Storage
     PatientName: Last^First^mid^pre
diff --git a/doc/release_notes/v3.1.0.rst b/doc/release_notes/v3.1.0.rst
index bc262a4563..3770cc0b7d 100644
--- a/doc/release_notes/v3.1.0.rst
+++ b/doc/release_notes/v3.1.0.rst
@@ -39,3 +39,4 @@ Enhancements
     * :attr:`~pydicom.uid.JPEGXLLossless`
     * :attr:`~pydicom.uid.JPEGXLJPEGRecompression`
     * :attr:`~pydicom.uid.JPEGXL`
+* Added ability to specify tag numbers in the CLI commands (allows private tags to be specified)
diff --git a/src/pydicom/cli/main.py b/src/pydicom/cli/main.py
index 2f616baab9..8dcc8878bd 100644
--- a/src/pydicom/cli/main.py
+++ b/src/pydicom/cli/main.py
@@ -25,12 +25,20 @@
 # Restrict the allowed syntax tightly, since use Python `eval`
 # on the expression. Do not allow callables, or assignment, for example.
 re_kywd_or_item = (
+    r"("
     r"\w+"  # Keyword (\w allows underscore, needed for file_meta)
+    r"|"  # or
+    r"\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)"  # DICOM hex tag (gggg,eeee)
+    r")"
     r"(\[(-)?\d+\])?"  # Optional [index] or [-index]
 )
 
 re_file_spec_object = re.compile(re_kywd_or_item + r"(\." + re_kywd_or_item + r")*$")
 
+re_tag_sub_from = r"\.\(([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)"
+re_tag_sub_to = r"[(0x\1,0x\2)].value"
+re_tag_with_spaces = r"\([0-9A-Fa-f]{4}, +[0-9A-Fa-f]{4}\)"
+
 filespec_help = (
     "File specification, in format [pydicom::]filename[::element]. "
     "If `pydicom::` prefix is present, then use the pydicom "
@@ -39,14 +47,22 @@
     "Examples: "
     "path/to/your_file.dcm, "
     "your_file.dcm::StudyDate, "
+    "your_file.dcm::(0001,0001), "
     "pydicom::rtplan.dcm::BeamSequence[0], "
-    "yourplan.dcm::BeamSequence[0].BeamNumber"
+    "yourplan.dcm::BeamSequence[0].BeamNumber, "
+    "pydicom::rtplan.dcm::(300A,00B0)[0].(300A,00B6)"
 )
 
 
 def eval_element(ds: Dataset, element: str) -> Any:
+    if element[0] != ".":
+        element = "." + element
+
+    # replace all ".(gggg,eeee)" hex tags with `eval`uable expression
+    element = re.sub(re_tag_sub_from, re_tag_sub_to, element)
+
     try:
-        return eval("ds." + element, {"ds": ds})
+        return eval("ds" + element, {"ds": ds})
     except AttributeError:
         raise argparse.ArgumentTypeError(
             f"Data element '{element}' is not in the dataset"
@@ -91,12 +107,18 @@ def filespec_parser(filespec: str) -> list[tuple[Dataset, Any]]:
         in format:
             [pydicom::]<filename>[::<element>]
         If an element is specified, it must be a path to a data element,
-        sequence item (dataset), or a sequence.
+        sequence item (dataset), or a sequence, specified with
+        DICOM keywords, or DICOM tags in the format (gggg,eeee).
+
         Examples:
             your_file.dcm
             your_file.dcm::StudyDate
+            pydicom::ct_small.dcm::(0019,0010)
             pydicom::rtplan.dcm::BeamSequence[0]
             pydicom::rtplan.dcm::BeamSequence[0].BeamLimitingDeviceSequence
+            pydicom::rtplan.dcm::(300A,00B0)[0]
+            pydicom::rtplan.dcm::(300A,00B0)[0].BeamLimitingDeviceSequence
+            pydicom::rtplan.dcm::(300A,00B0)[0].(300A,00B6)
 
     Returns
     -------
@@ -144,10 +166,17 @@ def filespec_parser(filespec: str) -> list[tuple[Dataset, Any]]:
 
     # Check element syntax first to avoid unnecessary load of file
     if element and not re_file_spec_object.match(element):
-        raise argparse.ArgumentTypeError(
-            f"Component '{element}' is not valid syntax for a "
-            "data element, sequence, or sequence item"
-        )
+        # Special message if a tag with spaces
+        if m := re.search(re_tag_with_spaces, element):
+            msg = (
+                f"Tag '{m.group()}' is not valid syntax for a " "tag: no spaces allowed"
+            )
+        else:
+            msg = (
+                f"Component '{element}' is not valid syntax for a "
+                "data element, sequence, or sequence item"
+            )
+        raise argparse.ArgumentTypeError(msg)
 
     # Read DICOM file
     try:
diff --git a/src/pydicom/data/data_manager.py b/src/pydicom/data/data_manager.py
index 91f2e232c6..c2bb34494a 100644
--- a/src/pydicom/data/data_manager.py
+++ b/src/pydicom/data/data_manager.py
@@ -6,8 +6,8 @@
 ---------------------
 
 *pydicom* can also search third-party data sources for matching data. To do so
-your project should register its entry points in its `setup.py` file. For
-example, a project named "mydata" with the interface class ``MyInterface``
+your project should register its entry points in its `pyproject.toml` or `setup.py` file. For
+example, a project named "mydata" using `setup.py` with the interface class ``MyInterface``
 should register:
 
 .. codeblock: python


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 39e684a198..2dcb5d67dc 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -13,7 +13,12 @@
     "no_callable()",
     "no_equals = ",
     "BeamSequence[0]extra",  # must match to end of string
+    "(300a,00b0)[0]extra",  # as above
     "BeamSequence[x]",  # index must be an int
+    "(0010,0010b)",  # bad tag format
+    "(0010, 0010)",  # space in tag
+    "(0010,  0010)",  # spaces in tag
+    "BeamSequence[0].(10,10)",  # nested bad tag format
 )
 
 missing_elements = (
@@ -31,8 +36,11 @@
 class TestFilespec:
     @pytest.mark.parametrize("bad_spec", bad_elem_specs)
     def test_syntax(self, bad_spec):
-        """Invalid syntax for for CLI file:element spec raises error"""
-        with pytest.raises(ArgumentTypeError, match=r".* syntax .*"):
+        """Invalid syntax for CLI file:element spec raises error"""
+        match = r".* syntax .*"
+        if ", " in bad_spec:
+            match += "tag: no spaces allowed"
+        with pytest.raises(ArgumentTypeError, match=match):
             filespec_parser(f"pydicom::rtplan.dcm::{bad_spec}")
 
     @pytest.mark.parametrize("missing_element", missing_elements)
@@ -75,35 +83,41 @@ class TestFilespecElementEval:
     # Load plan once
     plan, _ = filespec_parser("pydicom::rtplan.dcm")[0]
 
-    def test_correct_values(self):
+    # Test basic correct data elements
+    @pytest.mark.parametrize(
+        "elem_str, expected",
+        (
+            ("BeamSequence[0].ControlPointSequence[0].NominalBeamEnergy", 6.0),
+            ("PatientID", "id00001"),
+            ("(0010,0020)", "id00001"),
+        ),
+    )
+    def test_correct_data_elements(self, elem_str, expected):
         """CLI produces correct evaluation of requested element"""
-        # A nested data element
-        elem_str = "BeamSequence[0].ControlPointSequence[0].NominalBeamEnergy"
+        assert eval_element(self.plan, elem_str) == expected
+
+    # Test data elements in sequence items
+    @pytest.mark.parametrize(
+        "elem_str, expected",
+        (
+            ("BeamSequence[0].ControlPointSequence[0]", 6.0),
+            ("(300A,00B0)[0].ControlPointSequence[0]", 6.0),
+            ("(300a,00b0)[0].(300a,0111)[0]", 6.0),
+        ),
+    )
+    def test_data_elem_from_sequence_item(self, elem_str, expected):
         elem_val = eval_element(self.plan, elem_str)
-        assert 6.0 == elem_val
+        assert elem_val.NominalBeamEnergy == expected
 
-        # A nested Sequence item
-        elem_str = "BeamSequence[0].ControlPointSequence[0]"
-        elem_val = eval_element(self.plan, elem_str)
-        assert 6.0 == elem_val.NominalBeamEnergy
-
-        # A nested Sequence itself
-        elem_str = "BeamSequence[0].ControlPointSequence"
-        elem_val = eval_element(self.plan, elem_str)
+    def test_nested_sequence(self):
+        elem_val = eval_element(self.plan, "BeamSequence[0].ControlPointSequence")
         assert 6.0 == elem_val[0].NominalBeamEnergy
 
-        # A non-nested data element
-        elem_str = "PatientID"
-        elem_val = eval_element(self.plan, elem_str)
-        assert "id00001" == elem_val
-
-        # The file_meta or file_meta data element
-        elem_str = "file_meta"
-        elem_val = eval_element(self.plan, elem_str)
+    def test_file_meta_or_meta_element(self):
+        elem_val = eval_element(self.plan, "file_meta")
         assert "RT Plan Storage" == elem_val.MediaStorageSOPClassUID.name
 
-        elem_str = "file_meta.MediaStorageSOPClassUID"
-        elem_val = eval_element(self.plan, elem_str)
+        elem_val = eval_element(self.plan, "file_meta.MediaStorageSOPClassUID")
         assert "RT Plan Storage" == elem_val.name