Repository: Qiskit/qiskit

Issue:
Documentation build fails with Python 3.9
### Environment

- **Qiskit Terra version**: 0.20.0.dev @ b66030e
- **Python version**: 3.9
- **Operating system**: mac


### What is happening?

The documentation build fails when run on Python 3.9, due to failed forward references caused when the imported type alias `pulse.schedule.ScheduleComponent` is imported into `assemble.assemble_schedules`.  `ScheduleComponent` forward references `Schedule` within its own module (which is fine), but somewhere in our Sphinx build it becomes _not fine_ for the type alias to be imported into another module.

The two errors are:
```text
WARNING: Cannot resolve forward reference in type annotations of "qiskit.assembler.assemble_schedules": name 'Schedule' is not defined
WARNING: Cannot resolve forward reference in type annotations of "qiskit.assembler.assemble_schedules": name 'Schedule' is not defined
```
which are not super illuminating.

### How can we reproduce the issue?

Run the documentation build using a Python 3.9 environment and a recent version of Sphinx.  I was running a modified `tox` configuration that uses an isolated Python 3.9 environment but modifies the build command to
```bash
rm -rf docs/stubs docs/_build/html
sphinx-build -W --keep-going -bhtml docs docs/_build/html
```
(adds `--keep-going` for debugging purposes).

### What should happen?

The documentation should be able to build.

### Any suggestions?

From rereading the PEPs and `typing`'s documentation, I think everything that's being done in `pulse` should be acceptable (but importing forward references doesn't seem like an ideal choice).  I suspect the main issue is with `sphinx-autodoc-typehints`; it only resolves references at runtime, so it's no good guarding extra imports with `typing.TYPE_CHECKING` (tox-dev/sphinx-autodoc-typehints#22).

There look like there are two potential solutions:
- import the names into `assemble_schedules` so that the forward references resolve correctly.  This should be fine, because while `assemble_schedules` currently uses forward references to access `pulse.schedule`, there's no cyclic dependency reason for this.
- rearrange the `pulse.schedule` module so that the type aliases come at the end of the file, but then the alias union contains only concrete references, and the in-file uses of the alias do it by forward reference.

I think option 2 is probably better; it's slightly messier in the `schedule.py` file, but that's the one file where the forward references are 100% guaranteed to evaluate without any dodgy unused imports to set up the scope correctly.


Discussion:
Note that while it only throws an error on Python 3.9, this is actually buggy behaviour in all versions: the current documentation has unresolved `ForwardRef` instances in the documentation of [`qiskit.assembler.assemble_schedules`](https://qiskit.org/documentation/stubs/qiskit.assembler.assemble_schedules.html):

<img width="708" alt="Screenshot 2022-01-18 at 13 32 45" src="https://user-images.githubusercontent.com/5968590/149947032-1e23b8d5-84f6-4fb8-935f-e0074f8c9aef.png">
Imported forward references are actually just quite fragile.  The issue (if we can call it that) starts in the standard library, not `sphinx-autodoc-typehints`, although there's a chance the Sphinx extension could ameliorate the problem a little.  `typing.ForwardRef` has a field for its resolved form so each reference is only resolved once (`Union` promotes strings to `ForwardRef` on creation), but this makes it susceptible to issues if the first place that calls for its evaluation is not the same scope it was defined.  This happens in our Sphinx build, because the autosummary stub files are processed in alphabetical order, and so `assembler.assemble_schedules` is processed (and asked to resolve its type hints) _before_ `pulse.Schedule`.

The only current way to make this robust is to ensure that any type alias that may want to be imported into other modules uses only concrete references.

Resolving patch:
diff --git a/qiskit/assembler/assemble_schedules.py b/qiskit/assembler/assemble_schedules.py
--- a/qiskit/assembler/assemble_schedules.py
+++ b/qiskit/assembler/assemble_schedules.py
@@ -28,9 +28,9 @@
 def assemble_schedules(
     schedules: List[
         Union[
-            "schedule.ScheduleBlock",
-            "schedule.ScheduleComponent",
-            Tuple[int, "schedule.ScheduleComponent"],
+            schedule.ScheduleBlock,
+            schedule.ScheduleComponent,
+            Tuple[int, schedule.ScheduleComponent],
         ]
     ],
     qobj_id: int,
@@ -68,7 +68,7 @@ def assemble_schedules(
 
 
 def _assemble_experiments(
-    schedules: List[Union["schedule.ScheduleComponent", Tuple[int, "schedule.ScheduleComponent"]]],
+    schedules: List[Union[schedule.ScheduleComponent, Tuple[int, schedule.ScheduleComponent]]],
     lo_converter: converters.LoConfigConverter,
     run_config: RunConfig,
 ) -> Tuple[List[qobj.PulseQobjExperiment], Dict[str, Any]]:
diff --git a/qiskit/pulse/schedule.py b/qiskit/pulse/schedule.py
--- a/qiskit/pulse/schedule.py
+++ b/qiskit/pulse/schedule.py
@@ -56,12 +56,6 @@
 TimeSlots = Dict[Channel, List[Interval]]
 """List of timeslots occupied by instructions for each channel."""
 
-ScheduleComponent = Union["Schedule", Instruction]
-"""An element that composes a pulse schedule."""
-
-BlockComponent = Union["ScheduleBlock", Instruction]
-"""An element that composes a pulse schedule block."""
-
 
 class Schedule:
     """A quantum program *schedule* with exact time constraints for its instructions, operating
@@ -122,7 +116,7 @@ class Schedule:
 
     def __init__(
         self,
-        *schedules: Union[ScheduleComponent, Tuple[int, ScheduleComponent]],
+        *schedules: Union["ScheduleComponent", Tuple[int, "ScheduleComponent"]],
         name: Optional[str] = None,
         metadata: Optional[dict] = None,
     ):
@@ -246,7 +240,7 @@ def channels(self) -> Tuple[Channel]:
         return tuple(self._timeslots.keys())
 
     @property
-    def children(self) -> Tuple[Tuple[int, ScheduleComponent], ...]:
+    def children(self) -> Tuple[Tuple[int, "ScheduleComponent"], ...]:
         """Return the child schedule components of this ``Schedule`` in the
         order they were added to the schedule.
 
@@ -375,7 +369,7 @@ def _mutable_shift(self, time: int) -> "Schedule":
     def insert(
         self,
         start_time: int,
-        schedule: ScheduleComponent,
+        schedule: "ScheduleComponent",
         name: Optional[str] = None,
         inplace: bool = False,
     ) -> "Schedule":
@@ -392,7 +386,7 @@ def insert(
             return self._mutable_insert(start_time, schedule)
         return self._immutable_insert(start_time, schedule, name=name)
 
-    def _mutable_insert(self, start_time: int, schedule: ScheduleComponent) -> "Schedule":
+    def _mutable_insert(self, start_time: int, schedule: "ScheduleComponent") -> "Schedule":
         """Mutably insert `schedule` into `self` at `start_time`.
 
         Args:
@@ -407,7 +401,7 @@ def _mutable_insert(self, start_time: int, schedule: ScheduleComponent) -> "Sche
     def _immutable_insert(
         self,
         start_time: int,
-        schedule: ScheduleComponent,
+        schedule: "ScheduleComponent",
         name: Optional[str] = None,
     ) -> "Schedule":
         """Return a new schedule with ``schedule`` inserted into ``self`` at ``start_time``.
@@ -422,7 +416,7 @@ def _immutable_insert(
         return new_sched
 
     def append(
-        self, schedule: ScheduleComponent, name: Optional[str] = None, inplace: bool = False
+        self, schedule: "ScheduleComponent", name: Optional[str] = None, inplace: bool = False
     ) -> "Schedule":
         r"""Return a new schedule with ``schedule`` inserted at the maximum time over
         all channels shared between ``self`` and ``schedule``.
@@ -513,7 +507,7 @@ def exclude(
             self, filters=filters, negate=True, recurse_subroutines=check_subroutine
         )
 
-    def _add_timeslots(self, time: int, schedule: ScheduleComponent) -> None:
+    def _add_timeslots(self, time: int, schedule: "ScheduleComponent") -> None:
         """Update all time tracking within this schedule based on the given schedule.
 
         Args:
@@ -568,7 +562,7 @@ def _add_timeslots(self, time: int, schedule: ScheduleComponent) -> None:
 
         _check_nonnegative_timeslot(self._timeslots)
 
-    def _remove_timeslots(self, time: int, schedule: ScheduleComponent):
+    def _remove_timeslots(self, time: int, schedule: "ScheduleComponent"):
         """Delete the timeslots if present for the respective schedule component.
 
         Args:
@@ -607,7 +601,7 @@ def _remove_timeslots(self, time: int, schedule: ScheduleComponent):
             if not channel_timeslots:
                 self._timeslots.pop(channel)
 
-    def _replace_timeslots(self, time: int, old: ScheduleComponent, new: ScheduleComponent):
+    def _replace_timeslots(self, time: int, old: "ScheduleComponent", new: "ScheduleComponent"):
         """Replace the timeslots of ``old`` if present with the timeslots of ``new``.
 
         Args:
@@ -626,8 +620,8 @@ def _renew_timeslots(self):
 
     def replace(
         self,
-        old: ScheduleComponent,
-        new: ScheduleComponent,
+        old: "ScheduleComponent",
+        new: "ScheduleComponent",
         inplace: bool = False,
     ) -> "Schedule":
         """Return a ``Schedule`` with the ``old`` instruction replaced with a ``new``
@@ -750,11 +744,11 @@ def __len__(self) -> int:
         """Return number of instructions in the schedule."""
         return len(self.instructions)
 
-    def __add__(self, other: ScheduleComponent) -> "Schedule":
+    def __add__(self, other: "ScheduleComponent") -> "Schedule":
         """Return a new schedule with ``other`` inserted within ``self`` at ``start_time``."""
         return self.append(other)
 
-    def __or__(self, other: ScheduleComponent) -> "Schedule":
+    def __or__(self, other: "ScheduleComponent") -> "Schedule":
         """Return a new schedule which is the union of `self` and `other`."""
         return self.insert(0, other)
 
@@ -762,7 +756,7 @@ def __lshift__(self, time: int) -> "Schedule":
         """Return a new schedule which is shifted forward by ``time``."""
         return self.shift(time)
 
-    def __eq__(self, other: ScheduleComponent) -> bool:
+    def __eq__(self, other: "ScheduleComponent") -> bool:
         """Test if two Schedule are equal.
 
         Equality is checked by verifying there is an equal instruction at every time
@@ -1029,7 +1023,7 @@ def instructions(self) -> Tuple[Tuple[int, Instruction]]:
         return self.instructions
 
     @property
-    def blocks(self) -> Tuple[BlockComponent]:
+    def blocks(self) -> Tuple["BlockComponent", ...]:
         """Get the time-ordered instructions from self."""
         return tuple(self._blocks)
 
@@ -1048,7 +1042,7 @@ def ch_duration(self, *channels: Channel) -> int:
         return self.ch_duration(*channels)
 
     def append(
-        self, block: BlockComponent, name: Optional[str] = None, inplace: bool = True
+        self, block: "BlockComponent", name: Optional[str] = None, inplace: bool = True
     ) -> "ScheduleBlock":
         """Return a new schedule block with ``block`` appended to the context block.
         The execution time is automatically assigned when the block is converted into schedule.
@@ -1171,8 +1165,8 @@ def exclude(
 
     def replace(
         self,
-        old: BlockComponent,
-        new: BlockComponent,
+        old: "BlockComponent",
+        new: "BlockComponent",
         inplace: bool = True,
     ) -> "ScheduleBlock":
         """Return a ``ScheduleBlock`` with the ``old`` component replaced with a ``new``
@@ -1303,7 +1297,7 @@ def __repr__(self) -> str:
             self.__class__.__name__, blocks, name, repr(self.alignment_context)
         )
 
-    def __add__(self, other: BlockComponent) -> "ScheduleBlock":
+    def __add__(self, other: "BlockComponent") -> "ScheduleBlock":
         """Return a new schedule with ``other`` inserted within ``self`` at ``start_time``."""
         return self.append(other)
 
@@ -1489,7 +1483,7 @@ def _check_nonnegative_timeslot(timeslots: TimeSlots):
                 raise PulseError(f"An instruction on {chan} has a negative starting time.")
 
 
-def _get_timeslots(schedule: ScheduleComponent) -> TimeSlots:
+def _get_timeslots(schedule: "ScheduleComponent") -> TimeSlots:
     """Generate timeslots from given schedule component.
 
     Args:
@@ -1508,3 +1502,18 @@ def _get_timeslots(schedule: ScheduleComponent) -> TimeSlots:
         raise PulseError(f"Invalid schedule type {type(schedule)} is specified.")
 
     return timeslots
+
+
+# These type aliases are defined at the bottom of the file, because as of 2022-01-18 they are
+# imported into other parts of Terra.  Previously, the aliases were at the top of the file and used
+# forwards references within themselves.  This was fine within the same file, but causes scoping
+# issues when the aliases are imported into different scopes, in which the `ForwardRef` instances
+# would no longer resolve.  Instead, we only use forward references in the annotations of _this_
+# file to reference the aliases, which are guaranteed to resolve in scope, so the aliases can all be
+# concrete.
+
+ScheduleComponent = Union[Schedule, Instruction]
+"""An element that composes a pulse schedule."""
+
+BlockComponent = Union[ScheduleBlock, Instruction]
+"""An element that composes a pulse schedule block."""


Test patch: