Repository: pydicom/pydicom

Issue:
DA class is inconsistent
**Describe the bug**
pydicom.valuerep.DA accepts strings or datetime.date objects - but DA objects created with datetime.date inputs are invalid. 

**Expected behavior**
I would expect both of these expressions to generate the same output:
```
print(f'DA("20201117") => {DA("20201117")}')
print(f'DA(date(2020, 11, 17)) => {DA(date(2020, 11, 17))}')
```
but instead I get
```
DA("20201117") => 20201117
DA(date(2020, 11, 17)) => 2020-11-17
```
The hyphens inserted into the output are not valid DICOM - see the DA description in [Table 6.2-1](http://dicom.nema.org/dicom/2013/output/chtml/part05/sect_6.2.html)

**Steps To Reproduce**
Run the following commands:
```
from pydicom.valuerep import DA
from pydicom.dataset import Dataset
from datetime import date, datetime

print(f'DA("20201117") => {DA("20201117")}')
print(f'DA(date(2020, 11, 17)) => {DA(date(2020, 11, 17))}')

# 1. JSON serialization with formatted string works
ds = Dataset()
ds.ContentDate = '20201117'
json_output = ds.to_json()
print(f'json_output works = {json_output}')

# 2. JSON serialization with date object input is invalid.
ds = Dataset()
ds.ContentDate = str(DA(date(2020, 11, 17)))
json_output = ds.to_json()
print(f'json_output with str(DA..) - invalid DICOM {json_output}')

# 3. JSON serialization with date object fails
ds = Dataset()
ds.ContentDate = DA(date(2020, 11, 17))

# Exception on this line: TypeError: Object of type DA is not JSON serializable
json_output = ds.to_json()

```

I believe that all three approaches should work - but only the first is valid. The method signature on DA's `__new__` method accepts datetime.date objects. 

**Your environment**
```
module       | version
------       | -------
platform     | macOS-10.15.7-x86_64-i386-64bit
Python       | 3.8.6 (default, Oct  8 2020, 14:06:32)  [Clang 12.0.0 (clang-1200.0.32.2)]
pydicom      | 2.1.0
gdcm         | _module not found_
jpeg_ls      | _module not found_
numpy        | 1.19.4
PIL          | 8.0.1
```




Discussion:
Thanks for this - I agree that the print output should be consistent, and that the class should work correctly for json.

I think that the problem is in the _DateTimeBase method __str__ - there is no 'original_string' so it's calling the __str__ method if the object's superclass and the default for datetime.date puts in the hyphens.  The 'DT' class has a similar problem - it's not formatting the time the DICOM way.
The easiest way to handle this is probably to add an `original_string` attribute to `DA`, `TM` and `DT`, and set this attribute to the canonical DICOM representation if initialized with a date or time. This would only change the behavior of `__str__` and `__repr__` for these types, and I think that this is indeed the expected behavior.
@darcymason , what do you think? This would actually be a small change, that could make it into the next release.

Patch:
diff --git a/pydicom/valuerep.py b/pydicom/valuerep.py
--- a/pydicom/valuerep.py
+++ b/pydicom/valuerep.py
@@ -135,6 +135,8 @@ def __init__(self, val: Union[str, _DA, datetime.date]) -> None:
             self.original_string = val
         elif isinstance(val, DA) and hasattr(val, 'original_string'):
             self.original_string = val.original_string
+        elif isinstance(val, datetime.date):
+            self.original_string = f"{val.year}{val.month:02}{val.day:02}"
 
 
 class DT(_DateTimeBase, datetime.datetime):
@@ -242,6 +244,22 @@ def __init__(self, val: Union[str, _DT, datetime.datetime]) -> None:
             self.original_string = val
         elif isinstance(val, DT) and hasattr(val, 'original_string'):
             self.original_string = val.original_string
+        elif isinstance(val, datetime.datetime):
+            self.original_string = (
+                f"{val.year:04}{val.month:02}{val.day:02}"
+                f"{val.hour:02}{val.minute:02}{val.second:02}"
+            )
+            # milliseconds are seldom used, add them only if needed
+            if val.microsecond > 0:
+                self.original_string += f".{val.microsecond:06}"
+            if val.tzinfo is not None:
+                offset = val.tzinfo.utcoffset(val)
+                offset_min = offset.days * 24 * 60 + offset.seconds // 60
+                sign = "+" if offset_min >= 0 else "-"
+                offset_min = abs(offset_min)
+                self.original_string += (
+                    f"{sign}{offset_min // 60:02}{offset_min % 60:02}"
+                )
 
 
 class TM(_DateTimeBase, datetime.time):
@@ -318,6 +336,13 @@ def __init__(self, val: Union[str, _TM, datetime.time]) -> None:
             self.original_string = val
         elif isinstance(val, TM) and hasattr(val, 'original_string'):
             self.original_string = val.original_string
+        elif isinstance(val, datetime.time):
+            self.original_string = (
+                f"{val.hour:02}{val.minute:02}{val.second:02}"
+            )
+            # milliseconds are seldom used, add them only if needed
+            if val.microsecond > 0:
+                self.original_string += f".{val.microsecond:06}"
 
     if platform.python_implementation() == "PyPy":
         # Workaround for CPython/PyPy bug in time.__reduce_ex__()


Test patch:
diff --git a/pydicom/tests/test_filewriter.py b/pydicom/tests/test_filewriter.py
--- a/pydicom/tests/test_filewriter.py
+++ b/pydicom/tests/test_filewriter.py
@@ -300,8 +300,7 @@ def test_multivalue_DA(self):
         DA_expected = date(1961, 8, 4)
         tzinfo = timezone(timedelta(seconds=-21600), '-0600')
         multi_DT_expected = (datetime(1961, 8, 4), datetime(
-            1963, 11, 22, 12, 30, 0, 0,
-            timezone(timedelta(seconds=-21600), '-0600')))
+            1963, 11, 22, 12, 30, 0, 0, tzinfo))
         multi_TM_expected = (time(1, 23, 45), time(11, 11, 11))
         TM_expected = time(11, 11, 11, 1)
         ds = dcmread(datetime_name)
diff --git a/pydicom/tests/test_valuerep.py b/pydicom/tests/test_valuerep.py
--- a/pydicom/tests/test_valuerep.py
+++ b/pydicom/tests/test_valuerep.py
@@ -69,11 +69,15 @@ def test_pickling(self):
 
     def test_str(self):
         """Test str(TM)."""
-        x = pydicom.valuerep.TM("212223")
-        assert "212223" == str(x)
-        del x.original_string
-        assert not hasattr(x, 'original_string')
-        assert "21:22:23" == str(x)
+        assert "212223.1234" == str(pydicom.valuerep.TM("212223.1234"))
+        assert "212223" == str(pydicom.valuerep.TM("212223"))
+        assert "212223" == str(pydicom.valuerep.TM("212223"))
+        assert "2122" == str(pydicom.valuerep.TM("2122"))
+        assert "21" == str(pydicom.valuerep.TM("21"))
+        assert "212223" == str(pydicom.valuerep.TM(time(21, 22, 23)))
+        assert "212223.000024" == str(
+            pydicom.valuerep.TM(time(21, 22, 23, 24)))
+        assert "010203" == str(pydicom.valuerep.TM(time(1, 2, 3)))
 
     def test_new_empty_str(self):
         """Test converting an empty string."""
@@ -185,6 +189,18 @@ def test_new_str_conversion(self):
         with pytest.raises(ValueError, match=msg):
             pydicom.valuerep.DT("a2000,00,00")
 
+    def test_str(self):
+        dt = datetime(1911, 12, 13, 21, 21, 23)
+        assert "19111213212123" == str(pydicom.valuerep.DT(dt))
+        assert "19111213212123" == str(pydicom.valuerep.DT("19111213212123"))
+        assert "1001.02.03" == str(pydicom.valuerep.DA("1001.02.03"))
+        tz_info = timezone(timedelta(seconds=21600), '+0600')
+        dt = datetime(2022, 1, 2, 8, 9, 7, 123456, tzinfo=tz_info)
+        assert "20220102080907.123456+0600" == str(pydicom.valuerep.DT(dt))
+        tz_info = timezone(timedelta(seconds=-23400), '-0630')
+        dt = datetime(2022, 12, 31, 23, 59, 59, 42, tzinfo=tz_info)
+        assert "20221231235959.000042-0630" == str(pydicom.valuerep.DT(dt))
+
 
 class TestDA:
     """Unit tests for pickling DA"""
@@ -213,6 +229,11 @@ def test_new_obj_conversion(self):
         with pytest.raises(ValueError, match=msg):
             pydicom.valuerep.DA(123456)
 
+    def test_str(self):
+        assert "10010203" == str(pydicom.valuerep.DA(date(1001, 2, 3)))
+        assert "10010203" == str(pydicom.valuerep.DA("10010203"))
+        assert "1001.02.03" == str(pydicom.valuerep.DA("1001.02.03"))
+
 
 class TestIsValidDS:
     """Unit tests for the is_valid_ds function."""