Repository: pydicom/pydicom

Issue:
Type error with `examples/memory_dataset.py`
**Describe the bug**
There is a type error with `examples/memory_dataset.py`.

When running mypy (0.910) I see this:
```
examples/memory_dataset.py:30: error: Argument 1 to "dcmwrite" has incompatible type "DicomFileLike"; expected "Union[Union[str, bytes, PathLike[Any]], BinaryIO]"
```
I'm not sure if this is because the example should be updated or because the type declaration for dcmwrite should be changed. Either DicomFIleLike should be allowed as input like for dcmread or maybe the example should be updated to just pass the buffer in as this line https://github.com/hackermd/pydicom/blob/7b50d47740b468b468577a7e2adcfff6607c5d3b/pydicom/filewriter.py#L1077 will convert it to a DicomFileLike anyway.

**Expected behavior**
All examples should type check with no errors.

**Steps To Reproduce**
`mypy examples/memory_dataset.py`

**Your environment**
```
module       | version
------       | -------
platform     | macOS-11.6-x86_64-i386-64bit
Python       | 3.9.7 (default, Sep  1 2021, 12:35:15)  [Clang 12.0.5 (clang-1205.0.22.9)]
pydicom      | 2.2.2
gdcm         | _module not found_
jpeg_ls      | _module not found_
numpy        | 1.20.2
PIL          | 8.2.0
pylibjpeg    | _module not found_
openjpeg     | _module not found_
libjpeg      | _module not found_
```

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7446efc6b..bc0768eeb 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,13 +2,13 @@
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
 - repo: https://github.com/crate-ci/typos
-  rev: v1.16.21
+  rev: typos-dict-v0.11.3
   hooks:
     - id: typos
       exclude: _.*_dict.py
 - repo: https://github.com/astral-sh/ruff-pre-commit
   # Ruff version.
-  rev: v0.1.3
+  rev: v0.1.4
   hooks:
   - id: ruff
     args: [src, --fix, --exit-non-zero-on-fix]
diff --git a/doc/release_notes/v3.0.0.rst b/doc/release_notes/v3.0.0.rst
index 152d8aefa..d9d4731b7 100644
--- a/doc/release_notes/v3.0.0.rst
+++ b/doc/release_notes/v3.0.0.rst
@@ -60,6 +60,10 @@ Changes
   module instead
 * :func:`~pydicom.data_manager.get_testdata_file` and :func:`~pydicom.data_manager.get_testdata_files`
   now raise ``ValueError`` if called with an absolute path or pattern
+* :func:`~pydicom.uid.generate_uid` has been changed to use a random suffix
+  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`)
 
 
 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/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.
 
diff --git a/src/pydicom/uid.py b/src/pydicom/uid.py
index 164c20ed7..4f11c469c 100644
--- a/src/pydicom/uid.py
+++ b/src/pydicom/uid.py
@@ -5,9 +5,10 @@ import hashlib
 import os
 import random
 import re
+import secrets
 import sys
-import uuid
 from typing import Any
+import uuid
 
 from pydicom import config
 from pydicom._uid_dict import UID_dictionary
@@ -375,6 +376,9 @@ UncompressedTransferSyntaxes = [
 """Uncompressed (native) transfer syntaxes."""
 
 
+_MAX_PREFIX_LENGTH = 54
+
+
 def generate_uid(
     prefix: str | None = PYDICOM_ROOT_UID,
     entropy_srcs: list[str] | None = None,
@@ -386,19 +390,26 @@ def generate_uid(
        When `prefix` is ``None`` a conformant UUID suffix of up to
        39 characters will be used instead of a hashed value.
 
+    .. versionchanged:: 3.0
+
+       * When `entropy_srcs` is ``None`` the suffix is now generated using
+         :func:`~secrets.randbelow`
+       * The maximum length of `prefix` is now 54 characters
+
     Parameters
     ----------
     prefix : str or None, optional
         The UID prefix to use when creating the UID. Default is the *pydicom*
-        root UID ``'1.2.826.0.1.3680043.8.498.'``. If not used then a prefix of
-        ``'2.25.'`` will be used with the integer form of a UUID generated
-        using the :func:`uuid.uuid4` algorithm.
+        root UID ``'1.2.826.0.1.3680043.8.498.'``. If `prefix` is ``None`` then
+        a prefix of ``'2.25.'`` will be used with the integer form of a UUID
+        generated using the :func:`uuid.uuid4` algorithm.
     entropy_srcs : list of str, optional
         If `prefix` is used then the `prefix` will be appended with a
         SHA512 hash of the supplied :class:`list` which means the result is
         deterministic and should make the original data unrecoverable. If
-        `entropy_srcs` isn't used then random data will be appended instead
-        (default). If `prefix` is not used then `entropy_srcs` has no effect.
+        `entropy_srcs` isn't used then a random number from
+        :func:`secrets.randbelow` will be appended to the `prefix`. If `prefix`
+        is ``None`` then `entropy_srcs` has no effect.
 
     Returns
     -------
@@ -408,7 +419,7 @@ def generate_uid(
     Raises
     ------
     ValueError
-        If `prefix` is invalid or greater than 63 characters.
+        If `prefix` is invalid or greater than 54 characters.
 
     Examples
     --------
@@ -422,31 +433,39 @@ def generate_uid(
     1.2.826.0.1.3680043.8.498.87507166259346337659265156363895084463
     >>> generate_uid(entropy_srcs=['lorem', 'ipsum'])
     1.2.826.0.1.3680043.8.498.87507166259346337659265156363895084463
+
+    References
+    ----------
+
+    * DICOM Standard, Part 5, :dcm:`Chapters 9<part05/chapter_9.html>` and
+      :dcm:`Annex B<part05/chapter_B.html>`
+    * ISO/IEC 9834-8/`ITU-T X.667 <https://www.itu.int/rec/T-REC-X.667-201210-I/en>`_
     """
     if prefix is None:
         # UUID -> as 128-bit int -> max 39 characters long
         return UID(f"2.25.{uuid.uuid4().int}")
 
-    max_uid_len = 64
-    if len(prefix) > max_uid_len - 1:
-        raise ValueError("The prefix must be less than 63 chars")
-    if not re.match(RE_VALID_UID_PREFIX, prefix):
-        raise ValueError("The prefix is not in a valid format")
+    if len(prefix) > _MAX_PREFIX_LENGTH:
+        raise ValueError(
+            f"The 'prefix' should be no more than {_MAX_PREFIX_LENGTH} characters long"
+        )
 
-    avail_digits = max_uid_len - len(prefix)
+    if not re.match(RE_VALID_UID_PREFIX, prefix):
+        raise ValueError(
+            "The 'prefix' is not valid for use with a UID, see Part 5, Section "
+            "9.1 of the DICOM Standard"
+        )
 
     if entropy_srcs is None:
-        entropy_srcs = [
-            str(uuid.uuid1()),  # 128-bit from MAC/time/randomness
-            str(os.getpid()),  # Current process ID
-            hex(random.getrandbits(64)),  # 64 bits randomness
-        ]
+        maximum = 10 ** (64 - len(prefix))
+        # randbelow is in [0, maximum)
+        # {prefix}.0, and {prefix}0 are both valid
+        return UID(f"{prefix}{secrets.randbelow(maximum)}"[:64])
+
     hash_val = hashlib.sha512("".join(entropy_srcs).encode("utf-8"))
 
     # Convert this to an int with the maximum available digits
-    dicom_uid = prefix + str(int(hash_val.hexdigest(), 16))[:avail_digits]
-
-    return UID(dicom_uid)
+    return UID(f"{prefix}{int(hash_val.hexdigest(), 16)}"[:64])
 
 
 # Only auto-generated Storage SOP Class UIDs below - do not edit manually


Test patch:
diff --git a/tests/test_data_manager.py b/tests/test_data_manager.py
index 5ba75723a..5e106512c 100644
--- a/tests/test_data_manager.py
+++ b/tests/test_data_manager.py
@@ -6,6 +6,7 @@ import os
 from os.path import basename
 from pathlib import Path
 import shutil
+from tempfile import TemporaryDirectory
 
 import pytest
 
@@ -24,9 +25,16 @@ from pydicom.data import download
 from pydicom.data.download import get_data_dir, calculate_file_hash, get_cached_filehash
 
 
-EXT_PYDICOM = False
-if "pydicom-data" in external_data_sources():
-    EXT_PYDICOM = True
+EXT_PYDICOM = "pydicom-data" in external_data_sources()
+if EXT_PYDICOM:
+    DATA_SRC = external_data_sources()["pydicom-data"].data_path
+    try:
+        with open(DATA_SRC / "test", "wb") as f:
+            pass
+        os.remove(DATA_SRC / "test")
+        IS_WRITEABLE = True
+    except Exception as exc:
+        IS_WRITEABLE = False
 
 
 @pytest.fixture
@@ -131,7 +139,9 @@ class TestGetData:
             get_testdata_files("/foo/*.dcm")
 
 
-@pytest.mark.skipif(not EXT_PYDICOM, reason="pydicom-data not installed")
+@pytest.mark.skipif(
+    not EXT_PYDICOM or not IS_WRITEABLE, reason="pydicom-data not installed or RO"
+)
 class TestExternalDataSource:
     """Tests for the external data sources."""
 
@@ -139,14 +149,15 @@ class TestExternalDataSource:
         self.dpath = external_data_sources()["pydicom-data"].data_path
 
         # Backup the 693_UNCI.dcm file
-        p = self.dpath / "693_UNCI.dcm"
-        shutil.copy(p, self.dpath / "PYTEST_BACKUP")
+        self.src = self.dpath / "693_UNCI.dcm"
+        self.tdir = TemporaryDirectory(ignore_cleanup_errors=True)
+        self.dst = Path(self.tdir.name) / "PYTEST_BACKUP"
+        shutil.copy(self.src, self.dst)
 
     def teardown_method(self):
         # Restore the backed-up file
-        p = self.dpath / "693_UNCI.dcm"
-        shutil.copy(self.dpath / "PYTEST_BACKUP", p)
-        os.remove(self.dpath / "PYTEST_BACKUP")
+        shutil.copy(self.dst, self.src)
+        self.tdir.cleanup()
 
         if "mylib" in external_data_sources():
             del external_data_sources()["mylib"]
diff --git a/tests/test_uid.py b/tests/test_uid.py
index 41322137d..2393761ff 100644
--- a/tests/test_uid.py
+++ b/tests/test_uid.py
@@ -36,7 +36,7 @@ class TestGenerateUID:
 
         # Test invalid UID prefixes
         for invalid_prefix in (
-            ("1" * 63) + ".",
+            ("1" * 54) + ".",
             "",
             ".",
             "1",
@@ -54,7 +54,7 @@ class TestGenerateUID:
             "1.",
             "1.23.",
             "1.0.23.",
-            ("1" * 62) + ".",
+            ("1" * 53) + ".",
             "1.2.3.444444.",
         ):
             uid = generate_uid(prefix=valid_prefix)