EDIT : This answer is incorrect. Please read the explanation in the next section.
It is because when you try to import the modules when you are in the same directory as pkg, the relative import happens from where pkg is. That means that the following statement in bar.py
from . import foo as f
attempts to find foo in the parent directory of pkg.
You could choose to write the following import statement in pkg/bar.py instead (I personally prefer this method because it doesn't modify the path)
import pkg.foo as f
Or you could choose to modify the path variable within your __init__.py file
import os, sys
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(script_dir)
And then, add the following import statement in bar.py
import foo as f
===============================================================
ATTEMPT 2
So deceze has provided a really nice answer which I will try to expand upon.
What you are doing is quite similar to what is stated in the Python docs in 5.4.2. Submodules:
When a submodule is loaded using any mechanism (e.g. importlib APIs, the import or import-from statements, or built-in __import__()) a binding is placed in the parent module’s namespace to the submodule object. For example, if package spam has a submodule foo, after importing spam.foo, spam will have an attribute foo which is bound to the submodule. Let’s say you have the following directory structure:
spam/
__init__.py
foo.py
and spam/__init__.py has the following line in it:
from .foo import Foo
then executing the following puts name bindings for foo and Foo in the spam module:
>>>import spam
>>>spam.foo
<module 'spam.foo' from '/tmp/imports/spam/foo.py'>
>>>spam.Foo
<class 'spam.foo.Foo'>
Given Python’s familiar name binding rules this might seem surprising, but it’s actually a fundamental feature of the import system. The invariant holding is that if you have sys.modules['spam'] and sys.modules['spam.foo'] (as you would after the above import), the latter must appear as the foo attribute of the former.
Similar to what is mentioned in the docs, when you add the statement
# pkg/__init__.py
from . import foo as f
foo
print("Hello!")
in __init__.py and then import pkg, pkg.foo exists because of the import statement, which is why NameError is not raised. Therefore, importing pkg will be successful. You can check that pkg.foo is referring to foo.py if you check the value of pkg.foo within the terminal - you will see a similar return value to
<module 'pkg.foo' from 'absolute/path/to/pkg/foo.py'>
Now adding the following code in bar.py
# pkg/bar.py
from . import foo as f
foo
print("Hello!")
and attempting to execute
import pkg.bar
does raise the NameError exception because an object foo does not exist within bar.py; foo does not exist within the namespace of bar.py.
There is something interesting that is also happening when you used an alias to import foo within __init__.py. It is actually possible to overwrite the reference pkg.foo while still maintaining access to the contents of foo.py. If you added the following block within your __init__.py file after foo
class foo:
pass
then checking the reference pkg.foo will return
<class 'pkg.foo'>
but contents of foo.py can still be accessed by using pkg.f. If you check the value of pkg.f, you will see someting similar to
<module 'pkg.foo' from 'absolute/path/to/pkg/foo.py'>