Repository: pydicom/pydicom

Issue:
SQ elements return wrong VM
[SQ elements always have a VM of 1](http://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_7.5.html), however:
```python
>>> from pydicom import Dataset
>>> ds = Dataset()
>>> ds.BeamSequence = [Dataset(), Dataset()]
>>> ds["BeamSequence"].VM
2
```
From what I understand, this is also the case for an empty sequence, which is really annoying from a usage point of view. It seems like the value is the "sequence", which may or may not contain items. 

Technically this is understandable as the DICOM Standard doesn't conflate a SQ element's sequence with a non-SQ element's multi-value, but in pydicom they're both represented as `MutableSequence`, so it may be a bit confusing as a naive user probably expects the same behaviour in both cases.

Still, I think it'd make sense to follow the Standard's VM definition here and deal with any user confusion. The Standard is pretty explicit.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pydicom/pydicom/pull/1938?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) Report
> Merging [#1938](https://app.codecov.io/gh/pydicom/pydicom/pull/1938?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) (14c10a5) into [main](https://app.codecov.io/gh/pydicom/pydicom/commit/af5f54e3bb71939198a5eabb57bad2c11253cfd3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) (af5f54e) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

```diff
@@           Coverage Diff           @@
##             main    #1938   +/-   ##
=======================================
  Coverage   97.74%   97.74%           
=======================================
  Files          63       63           
  Lines       10743    10747    +4     
=======================================
+ Hits        10501    10505    +4     
  Misses        242      242           
```


| [Files](https://app.codecov.io/gh/pydicom/pydicom/pull/1938?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) | Coverage Δ | |
|---|---|---|
| [src/pydicom/dataelem.py](https://app.codecov.io/gh/pydicom/pydicom/pull/1938?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom#diff-c3JjL3B5ZGljb20vZGF0YWVsZW0ucHk=) | `98.45% <100.00%> (+0.01%)` | :arrow_up: |

:mega: Codecov offers a browser extension for seamless coverage viewing on GitHub. Try it in [Chrome](https://chrome.google.com/webstore/detail/codecov/gedikamndpbemklijjkncpnolildpbgo) or [Firefox](https://addons.mozilla.org/en-US/firefox/addon/codecov/) today!


Patch:
diff --git a/doc/release_notes/v3.0.0.rst b/doc/release_notes/v3.0.0.rst
index d9d4731b7..d9f6c8413 100644
--- a/doc/release_notes/v3.0.0.rst
+++ b/doc/release_notes/v3.0.0.rst
@@ -64,6 +64,8 @@ Changes
   generated using :func:`~secrets.randbelow` when `entropy_srcs` isn't used, and
   the maximum allowed length of the `prefix` has been changed to 54 characters
   (:issue:`1773`)
+* :attr:`~pydicom.dataelem.DataElement.VM` always returns ``1`` for **SQ**
+  elements (:issue:`1481`)
 
 
 Enhancements
diff --git a/examples/dicomtree.py b/examples/dicomtree.py
index b395b73dd..e281f2914 100644
--- a/examples/dicomtree.py
+++ b/examples/dicomtree.py
@@ -5,94 +5,76 @@ Show a dicom file using hierarchical tree
 =========================================
 Show a dicom file using a hierarchical tree in a graphical window.
 
-sudo apt install tix-dev
-sudo pip install -U pydicom
-python3 dicomtree.py file.dcm
-Or in file browser, right click on file.dcm, open with custom command:
-python3 dicomtree.py
 """
+from pathlib import Path
+import sys
 
+import tkinter as tk
+from tkinter import ttk
+
+import pydicom
 
-import tkinter.tix as tkinter_tix
 
 print(__doc__)
 
-usage = "Usage: python dicomtree.py dicom_filename"
-
-
-def RunTree(w, filename):
-    top = tkinter_tix.Frame(w, relief=tkinter_tix.RAISED, bd=1)
-    tree = tkinter_tix.Tree(top, options="hlist.columns 2")
-    tree.pack(expand=1, fill=tkinter_tix.BOTH, padx=10, pady=10, side=tkinter_tix.LEFT)
-    # print(tree.hlist.keys())   # use to see the available configure() options
-    tree.hlist.configure(bg="white", font="Courier 10", indent=30)
-    tree.hlist.configure(selectbackground="light yellow", gap=150)
-
-    box = tkinter_tix.ButtonBox(w, orientation=tkinter_tix.HORIZONTAL)
-    # box.add('ok', text='Ok', underline=0, command=w.destroy, width=6)
-    box.add("exit", text="Exit", underline=0, command=w.destroy, width=6)
-    box.pack(side=tkinter_tix.BOTTOM, fill=tkinter_tix.X)
-    top.pack(side=tkinter_tix.TOP, fill=tkinter_tix.BOTH, expand=1)
-    # https://stackoverflow.com/questions/17355902/python-tkinter-binding-mousewheel-to-scrollbar
-    tree.bind_all(
-        "<MouseWheel>",
-        lambda event: tree.hlist.yview_scroll(  # Wheel in Windows
-            int(-1 * event.delta / 120.0), "units"
-        ),
-    )
-    tree.bind_all(
-        "<Button-4>",
-        lambda event: tree.hlist.yview_scroll(int(-1), "units"),  # Wheel up in Linux
-    )
-    tree.bind_all(
-        "<Button-5>",
-        lambda event: tree.hlist.yview_scroll(int(+1), "units"),  # Wheel down in Linux
-    )
-
-    show_file(filename, tree)
-
-
-def show_file(filename, tree):
-    tree.hlist.add("root", text=filename)
-    ds = pydicom.dcmread(filename)
-    ds.decode()  # change strings to unicode
-    recurse_tree(tree, ds, "root", False)
-    tree.autosetmode()
-
-
-def recurse_tree(tree, dataset, parent, hide=False):
-    # order the dicom tags
-    for data_element in dataset:
-        node_id = parent + "." + hex(id(data_element))
-        if isinstance(data_element.value, str):
-            tree.hlist.add(node_id, text=str(data_element))
-        else:
-            tree.hlist.add(node_id, text=str(data_element))
-        if hide:
-            tree.hlist.hide_entry(node_id)
-        if data_element.VR == "SQ":  # a sequence
-            for i, dataset in enumerate(data_element.value):
-                item_id = node_id + "." + str(i + 1)
-                sq_item_description = data_element.name.replace(
-                    " Sequence", ""
-                )  # XXX not i18n
-                item_text = f"{sq_item_description:s} {i + 1:d}"
-                tree.hlist.add(item_id, text=item_text)
-                tree.hlist.hide_entry(item_id)
-                recurse_tree(tree, dataset, item_id, hide=True)
 
+def build_tree(
+    tree: ttk.Treeview, ds: pydicom.Dataset, parent: str | None = None
+) -> None:
+    """Build out the tree.
+
+    Parameters
+    ----------
+    tree : ttk.Treeview
+        The treeview object.
+    ds : pydicom.dataset.Dataset
+        The dataset object to add to the `tree`.
+    parent : str | None
+        The item ID of the parent item in the tree (if any), default ``None``.
+    """
+    # For each DataElement in the current Dataset
+    for idx, elem in enumerate(ds):
+        tree_item = tree.insert("", tk.END, text=str(elem))
+        if parent:
+            tree.move(tree_item, parent, idx)
+
+        if elem.VR == "SQ":
+            # DataElement is a sequence, containing 0 or more Datasets
+            for seq_idx, seq_item in enumerate(elem.value):
+                tree_seq_item = tree.insert(
+                    "", tk.END, text=f"{elem.name} Item {seq_idx + 1}"
+                )
+                tree.move(tree_seq_item, tree_item, seq_idx)
+
+                # Recurse into the sequence item(s)
+                build_tree(tree, seq_item, tree_seq_item)
 
-if __name__ == "__main__":
-    import sys
-    import pydicom
 
+if __name__ == "__main__":
     if len(sys.argv) != 2:
-        print("Please supply a dicom file name:\n")
-        print(usage)
+        print("Please supply the path to a DICOM file: python dicomtree.py path")
         sys.exit(-1)
-    root = tkinter_tix.Tk()
-    root.geometry("1200x900+0+0")
-    root.title("DICOM tree viewer - " + sys.argv[1])
 
-    RunTree(root, sys.argv[1])
+    # Read the supplied DICOM dataset
+    path = Path(sys.argv[1]).resolve(strict=True)
+    ds = pydicom.dcmread(path)
+
+    # Create the root Tk widget
+    root = tk.Tk()
+    root.geometry("1200x900")
+    root.title(f"DICOM tree viewer - {path.name}")
+    root.rowconfigure(0, weight=1)
+    root.columnconfigure(0, weight=1)
+
+    # Use a monospaced font
+    s = ttk.Style()
+    s.theme_use("clam")
+    s.configure("Treeview", font=("Courier", 12))
+
+    # Create the tree and populate it
+    tree = ttk.Treeview(root)
+    build_tree(tree, ds, None)
+    tree.grid(row=0, column=0, sticky=tk.NSEW)
+
+    # Start the DICOM tree widget
     root.mainloop()
diff --git a/examples/input_output/plot_read_rtplan.py b/examples/input_output/plot_read_rtplan.py
index e893bf764..e33c57697 100644
--- a/examples/input_output/plot_read_rtplan.py
+++ b/examples/input_output/plot_read_rtplan.py
@@ -17,7 +17,7 @@ from pydicom.data import get_testdata_file
 print(__doc__)
 
 
-def list_beams(ds):
+def list_beams(ds: pydicom.Dataset) -> str:
     """Summarizes the RTPLAN beam information in the dataset."""
     lines = [f"{'Beam name':^13s} {'Number':^8s} {'Gantry':^8s} {'SSD (cm)':^11s}"]
     for beam in ds.BeamSequence:
diff --git a/examples/memory_dataset.py b/examples/memory_dataset.py
index 69450b763..768e06822 100644
--- a/examples/memory_dataset.py
+++ b/examples/memory_dataset.py
@@ -13,7 +13,7 @@ datasets saved as blobs in a database.
 
 from io import BytesIO
 
-from pydicom import dcmread, dcmwrite
+from pydicom import dcmread, dcmwrite, Dataset
 from pydicom.filebase import DicomFileLike
 
 print(__doc__)
@@ -21,20 +21,20 @@ print(__doc__)
 usage = "Usage: python memory_dataset.py dicom_filename"
 
 
-def write_dataset_to_bytes(dataset):
+def write_dataset_to_bytes(ds: Dataset) -> bytes:
     # create a buffer
     with BytesIO() as buffer:
         # create a DicomFileLike object that has some properties of DataSet
         memory_dataset = DicomFileLike(buffer)
         # write the dataset to the DicomFileLike object
-        dcmwrite(memory_dataset, dataset)
+        dcmwrite(memory_dataset, ds)
         # to read from the object, you have to rewind it
         memory_dataset.seek(0)
         # read the contents as bytes
         return memory_dataset.read()
 
 
-def adapt_dataset_from_bytes(blob):
+def adapt_dataset_from_bytes(blob: bytes) -> Dataset:
     # you can just read the dataset from the byte array
     dataset = dcmread(BytesIO(blob))
     # do some interesting stuff
@@ -45,13 +45,13 @@ def adapt_dataset_from_bytes(blob):
 
 
 class DummyDataBase:
-    def __init__(self):
-        self._blobs = {}
+    def __init__(self) -> None:
+        self._blobs: dict[str, bytes] = {}
 
-    def save(self, name, blob):
+    def save(self, name: str, blob: bytes) -> None:
         self._blobs[name] = blob
 
-    def load(self, name):
+    def load(self, name: str) -> bytes | None:
         return self._blobs.get(name)
 
 
@@ -77,8 +77,9 @@ if __name__ == "__main__":
     # Convert a byte array to a dataset:
     # - get the bytes from storage
     read_bytes = db.load("dataset")
-    # - convert the bytes into a dataset and do something interesting with it
-    read_dataset = adapt_dataset_from_bytes(read_bytes)
-    print(read_dataset)
-    # - you can write your dataset to a file if wanted
-    dcmwrite(file_path + "_new", read_dataset)
+    if read_bytes:
+        # - convert the bytes into a dataset and do something interesting with it
+        read_dataset = adapt_dataset_from_bytes(read_bytes)
+        print(read_dataset)
+        # - you can write your dataset to a file if wanted
+        dcmwrite(file_path + "_new", read_dataset)
diff --git a/examples/metadata_processing/plot_add_dict_entries.py b/examples/metadata_processing/plot_add_dict_entries.py
index 80003f8e5..1988c7604 100644
--- a/examples/metadata_processing/plot_add_dict_entries.py
+++ b/examples/metadata_processing/plot_add_dict_entries.py
@@ -10,11 +10,11 @@ pydicom's private dictionary when loading an existing dataset.
 # authors : Darcy Mason and pydicom contributors
 # license : MIT
 
+import io
 
 from pydicom import dcmread
 from pydicom.datadict import add_private_dict_entries
 from pydicom.dataset import Dataset
-from pydicom.filebase import DicomBytesIO
 from pydicom.valuerep import VR
 
 print(__doc__)
@@ -35,7 +35,7 @@ block.add_new(0x03, VR.UI, "1.2.3.4.5")
 
 # write the dataset into a memory file and read it back
 # this simulates reading from a normal DICOM file
-fp = DicomBytesIO()
+fp = io.BytesIO()
 ds.save_as(fp)
 ds = dcmread(fp, force=True)
 
diff --git a/src/pydicom/dataelem.py b/src/pydicom/dataelem.py
index 105348b6a..02487df26 100644
--- a/src/pydicom/dataelem.py
+++ b/src/pydicom/dataelem.py
@@ -452,15 +452,26 @@ class DataElement:
 
     @property
     def VM(self) -> int:
-        """Return the value multiplicity of the element as :class:`int`."""
+        """Return the value multiplicity of the element as :class:`int`.
+
+        .. versionchanged:: 3.0
+
+            **SQ** elements now always return a VM of ``1``.
+        """
+        if self.VR == VR_.SQ:
+            return 1
+
         if self.value is None:
             return 0
+
         if isinstance(self.value, str | bytes | PersonName):
             return 1 if self.value else 0
+
         try:
             iter(self.value)
         except TypeError:
             return 1
+
         return len(self.value)
 
     @property
@@ -469,6 +480,9 @@ class DataElement:
 
         .. versionadded:: 1.4
         """
+        if self.VR == VR_.SQ:
+            return not bool(self.value)
+
         return self.VM == 0
 
     @property
diff --git a/src/pydicom/filewriter.py b/src/pydicom/filewriter.py
index 5bdd2350d..3f9e90b26 100644
--- a/src/pydicom/filewriter.py
+++ b/src/pydicom/filewriter.py
@@ -886,7 +886,9 @@ def _write_dataset(fp: DicomIO, dataset: Dataset, write_like_original: bool) ->
 
 
 def dcmwrite(
-    filename: PathType | BinaryIO, dataset: Dataset, write_like_original: bool = True
+    filename: PathType | BinaryIO | DicomFileLike,
+    dataset: Dataset,
+    write_like_original: bool = True,
 ) -> None:
     """Write `dataset` to the `filename` specified.
 


Test patch:
diff --git a/tests/test_dataelem.py b/tests/test_dataelem.py
index 95bdfecf0..1bf205326 100644
--- a/tests/test_dataelem.py
+++ b/tests/test_dataelem.py
@@ -544,7 +544,6 @@ class TestDataElement:
         ds.AcquisitionContextSequence = []
         elem = ds["AcquisitionContextSequence"]
         assert bool(elem.value) is False
-        assert 0 == elem.VM
         assert elem.value == []
 
         fp = DicomBytesIO()
@@ -553,7 +552,6 @@ class TestDataElement:
         filewriter.write_dataset(fp, ds)
         ds_read = dcmread(fp, force=True)
         elem = ds_read["AcquisitionContextSequence"]
-        assert 0 == elem.VM
         assert elem.value == []
 
     def test_is_private(self):
@@ -563,6 +561,26 @@ class TestDataElement:
         elem = DataElement(0x00080010, "UN", None)
         assert not elem.is_private
 
+    def test_is_empty_sequence(self):
+        """Test DataElement.is_empty for SQ."""
+        elem = DataElement(0x300A00B0, "SQ", [])
+        assert elem.VR == "SQ"
+        assert len(elem.value) == 0
+        assert elem.is_empty
+        elem.value = [Dataset()]
+        assert len(elem.value) == 1
+        assert not elem.is_empty
+
+    def test_vm_sequence(self):
+        """Test DataElement.VM for SQ."""
+        elem = DataElement(0x300A00B0, "SQ", [])
+        assert elem.VR == "SQ"
+        assert len(elem.value) == 0
+        assert elem.VM == 1
+        elem.value = [Dataset(), Dataset()]
+        assert len(elem.value) == 2
+        assert elem.VM == 1
+
 
 class TestRawDataElement: