Repository: aio-libs/aioftp

Issue:
Leap Year Issue
Hi,

```
import asyncio
import aioftp

async def get_files_path(host, port, login, password):
    async with aioftp.ClientSession(host, port, login, password) as client:
        for path, info in (await client.list(PATH)):
            print(path)

asyncio.run(get_files_path(HOST, port, USERNAME, PASSWORD))
```

It seems that aioftp fails to list up the files created after 02/29/2020 00:00.
When I used Python ftplib, I could get the correct number of files totally the same with I was checking on FileZilla.

Can you check this issue? Thanks.

Comments:

Patch:
diff --git a/aioftp/client.py b/aioftp/client.py
index bcb854f..a1072b6 100644
--- a/aioftp/client.py
+++ b/aioftp/client.py
@@ -1,4 +1,5 @@
 import re
+import calendar
 import collections
 import pathlib
 import logging
@@ -26,6 +27,7 @@ from .common import (
     async_enterable,
     setlocale,
     HALF_OF_YEAR_IN_SECONDS,
+    TWO_YEARS_IN_SECONDS,
 )
 
 __all__ = (
@@ -354,7 +356,8 @@ class BaseClient:
         """
         return d.strftime("%Y%m%d%H%M00")
 
-    def parse_ls_date(self, s, *, now=None):
+    @classmethod
+    def parse_ls_date(cls, s, *, now=None):
         """
         Parsing dates from the ls unix utility. For example,
         "Nov 18  1958" and "Nov 18 12:29".
@@ -368,16 +371,29 @@ class BaseClient:
             try:
                 if now is None:
                     now = datetime.datetime.now()
-                d = datetime.datetime.strptime(s, "%b %d %H:%M")
-                d = d.replace(year=now.year)
-                diff = (now - d).total_seconds()
-                if diff > HALF_OF_YEAR_IN_SECONDS:
-                    d = d.replace(year=now.year + 1)
-                elif diff < -HALF_OF_YEAR_IN_SECONDS:
-                    d = d.replace(year=now.year - 1)
+                if s.startswith('Feb 29'):
+                    # Need to find the nearest previous leap year
+                    prev_leap_year = now.year
+                    while not calendar.isleap(prev_leap_year):
+                        prev_leap_year -= 1
+                    d = datetime.datetime.strptime(
+                        f"{prev_leap_year} {s}", "%Y %b %d %H:%M"
+                    )
+                    # Check if it's next leap year
+                    diff = (now - d).total_seconds()
+                    if diff > TWO_YEARS_IN_SECONDS:
+                        d = d.replace(year=prev_leap_year + 4)
+                else:
+                    d = datetime.datetime.strptime(s, "%b %d %H:%M")
+                    d = d.replace(year=now.year)
+                    diff = (now - d).total_seconds()
+                    if diff > HALF_OF_YEAR_IN_SECONDS:
+                        d = d.replace(year=now.year + 1)
+                    elif diff < -HALF_OF_YEAR_IN_SECONDS:
+                        d = d.replace(year=now.year - 1)
             except ValueError:
                 d = datetime.datetime.strptime(s, "%b %d  %Y")
-        return self.format_date_time(d)
+        return cls.format_date_time(d)
 
     def parse_list_line_unix(self, b):
         """
diff --git a/aioftp/common.py b/aioftp/common.py
index d94ea6a..50f766f 100644
--- a/aioftp/common.py
+++ b/aioftp/common.py
@@ -36,6 +36,7 @@ DEFAULT_USER = "anonymous"
 DEFAULT_PASSWORD = "anon@"
 DEFAULT_ACCOUNT = ""
 HALF_OF_YEAR_IN_SECONDS = 15778476
+TWO_YEARS_IN_SECONDS = ((365 * 3 + 366) * 24 * 60 * 60) / 2
 
 
 def _now():
diff --git a/aioftp/errors.py b/aioftp/errors.py
index fac144a..2955cc4 100644
--- a/aioftp/errors.py
+++ b/aioftp/errors.py
@@ -2,13 +2,21 @@ from . import common
 
 
 __all__ = (
+    "AIOFTPException",
     "StatusCodeError",
     "PathIsNotAbsolute",
     "PathIOError",
+    "NoAvailablePort",
 )
 
 
-class StatusCodeError(Exception):
+class AIOFTPException(Exception):
+    """
+    Base exception class.
+    """
+
+
+class StatusCodeError(AIOFTPException):
     """
     Raised for unexpected or "bad" status codes.
 
@@ -41,13 +49,13 @@ class StatusCodeError(Exception):
         self.info = info
 
 
-class PathIsNotAbsolute(Exception):
+class PathIsNotAbsolute(AIOFTPException):
     """
     Raised when "path" is not absolute.
     """
 
 
-class PathIOError(Exception):
+class PathIOError(AIOFTPException):
     """
     Universal exception for any path io errors.
 
@@ -67,7 +75,7 @@ class PathIOError(Exception):
         self.reason = reason
 
 
-class NoAvailablePort(OSError):
+class NoAvailablePort(AIOFTPException, OSError):
     """
     Raised when there is no available data port
     """
diff --git a/history.rst b/history.rst
index aa132a0..995b54b 100644
--- a/history.rst
+++ b/history.rst
@@ -4,6 +4,10 @@ x.x.x (xx-xx-xxxx)
 - server: remove obsolete `pass` to `pass_` command renaming
 Thanks to `Puddly <https://github.com/puddly>`_
 
+- client: fix leap year bug at `parse_ls_date` method
+- all: add base exception class
+Thanks to `decaz <https://github.com/decaz>`_
+
 0.15.0 (07-01-2019)
 -------------------
 


Test patch:
diff --git a/tests/test_simple_functions.py b/tests/test_simple_functions.py
index 1ecca61..cada55a 100644
--- a/tests/test_simple_functions.py
+++ b/tests/test_simple_functions.py
@@ -61,7 +61,56 @@ def _c_locale_time(d, format="%b %d %H:%M"):
         return d.strftime(format)
 
 
-def test_parse_list_datetime_not_older_than_6_month_format():
+def test_parse_ls_date_of_leap_year():
+    def date_to_p(d):
+        return d.strftime("%Y%m%d%H%M00")
+    p = aioftp.Client.parse_ls_date
+    # Leap year date to test
+    d = datetime.datetime(year=2000, month=2, day=29)
+    current_and_expected_dates = (
+        # 2016 (leap)
+        (
+            datetime.datetime(year=2016, month=2, day=29),
+            datetime.datetime(year=2016, month=2, day=29)
+        ),
+        # 2017
+        (
+            datetime.datetime(year=2017, month=2, day=28),
+            datetime.datetime(year=2016, month=2, day=29)
+        ),
+        (
+            datetime.datetime(year=2017, month=3, day=1),
+            datetime.datetime(year=2016, month=2, day=29)
+        ),
+        # 2018
+        (
+            datetime.datetime(year=2018, month=2, day=28),
+            datetime.datetime(year=2016, month=2, day=29)
+        ),
+        (
+            datetime.datetime(year=2018, month=3, day=1),
+            datetime.datetime(year=2020, month=2, day=29)
+        ),
+        # 2019
+        (
+            datetime.datetime(year=2019, month=2, day=28),
+            datetime.datetime(year=2020, month=2, day=29)
+        ),
+        (
+            datetime.datetime(year=2019, month=3, day=1),
+            datetime.datetime(year=2020, month=2, day=29)
+        ),
+        # 2020 (leap)
+        (
+            datetime.datetime(year=2020, month=2, day=29),
+            datetime.datetime(year=2020, month=2, day=29)
+        ),
+    )
+    for now, expected in current_and_expected_dates:
+        assert p(_c_locale_time(d), now=now) == date_to_p(expected)
+
+
+def test_parse_ls_date_not_older_than_6_month_format():
     def date_to_p(d):
         return d.strftime("%Y%m%d%H%M00")
     p = aioftp.Client.parse_ls_date
@@ -73,10 +122,10 @@ def test_parse_list_datetime_not_older_than_6_month_format():
     deltas = (datetime.timedelta(), dt, -dt)
     for now, delta in itertools.product(dates, deltas):
         d = now + delta
-        assert p(aioftp.Client, _c_locale_time(d), now=now) == date_to_p(d)
+        assert p(_c_locale_time(d), now=now) == date_to_p(d)
 
 
-def test_parse_list_datetime_older_than_6_month_format():
+def test_parse_ls_date_older_than_6_month_format():
     def date_to_p(d):
         return d.strftime("%Y%m%d%H%M00")
     p = aioftp.Client.parse_ls_date
@@ -92,10 +141,10 @@ def test_parse_list_datetime_older_than_6_month_format():
             expect = date_to_p(d.replace(year=d.year - 1))
         else:
             expect = date_to_p(d.replace(year=d.year + 1))
-        assert p(aioftp.Client, _c_locale_time(d), now=now) == expect
+        assert p(_c_locale_time(d), now=now) == expect
 
 
-def test_parse_list_datetime_short():
+def test_parse_ls_date_short():
     def date_to_p(d):
         return d.strftime("%Y%m%d%H%M00")
     p = aioftp.Client.parse_ls_date
@@ -105,7 +154,7 @@ def test_parse_list_datetime_short():
     )
     for d in dates:
         s = _c_locale_time(d, format="%b %d  %Y")
-        assert p(aioftp.Client, s) == date_to_p(d)
+        assert p(s) == date_to_p(d)
 
 
 def test_parse_list_line_unix():