CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/conan-io/conan?pullRequest=3365) <br/>Thank you for your submission, we really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/conan-io/conan?pullRequest=3365) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/conan-io/conan?pullRequest=3365) it.</sub>
danimtb: Hi @sigiesec 
Thanks a lot for this PR.

Could you please fix the test with right spelling too?
https://github.com/conan-io/conan/blob/f966d516452380918437888811bc833c804dac39/conans/test/model/version_ranges_test.py#L245

https://github.com/conan-io/conan/blob/f966d516452380918437888811bc833c804dac39/conans/test/model/version_ranges_test.py#L247

Many thanks!!
sigiesec: @memshared Sorry I missed those tests. I did not find them by grepping because they said "override" rather than "overriden". I changed this to "overridden" now.
diff --git a/conans/client/graph/graph_builder.py b/conans/client/graph/graph_builder.py
index a802a879b..c3e671d0e 100644
--- a/conans/client/graph/graph_builder.py
+++ b/conans/client/graph/graph_builder.py
@@ -140,7 +140,7 @@ class DepsGraphBuilder(object):
 
     def _config_node(self, node, down_reqs, down_ref, down_options):
         """ update settings and option in the current ConanFile, computing actual
-        requirement values, cause they can be overriden by downstream requires
+        requirement values, cause they can be overridden by downstream requires
         param settings: dict of settings values => {"os": "windows"}
         """
         try:
diff --git a/conans/client/rest/rest_client_common.py b/conans/client/rest/rest_client_common.py
index e9b38a7dc..ecfa18bdd 100644
--- a/conans/client/rest/rest_client_common.py
+++ b/conans/client/rest/rest_client_common.py
@@ -212,16 +212,6 @@ class RestCommonMethods(object):
         url = self._recipe_url(conan_reference) + "/remove_files"
         return self._post_json(url, payload)
 
-    @handle_return_deserializer()
-    def _remove_package_files(self, package_reference, files):
-        """ Remove package files """
-        self.check_credentials()
-        payload = {"files": [filename.replace("\\", "/") for filename in files]}
-        url = "%s/conans/%s/packages/%s/remove_files" % (self.remote_api_url,
-                                                         "/".join(package_reference.conan),
-                                                         package_reference.package_id)
-        return self._post_json(url, payload)
-
     def _post_json(self, url, payload):
         response = self.requester.post(url,
                                        auth=self.auth,
diff --git a/conans/client/rest/rest_client_v1.py b/conans/client/rest/rest_client_v1.py
index c419b04fb..768472291 100644
--- a/conans/client/rest/rest_client_v1.py
+++ b/conans/client/rest/rest_client_v1.py
@@ -276,7 +276,9 @@ class RestV1Methods(RestCommonMethods):
             self._output.writeln("")
             self._upload_files(urls, files_to_upload, self._output, retry, retry_wait)
         if deleted:
-            self._remove_package_files(package_reference, deleted)
+            raise Exception("This shouldn't be happening, deleted files "
+                            "in local package present in remote: %s.\n Please, report it at "
+                            "https://github.com/conan-io/conan/issues " % str(deleted))
 
         logger.debug("====> Time rest client upload_package: %f" % (time.time() - t1))
         return files_to_upload or deleted
diff --git a/conans/client/rest/uploader_downloader.py b/conans/client/rest/uploader_downloader.py
index b52a9aac6..cff66cbfa 100644
--- a/conans/client/rest/uploader_downloader.py
+++ b/conans/client/rest/uploader_downloader.py
@@ -115,7 +115,7 @@ class Downloader(object):
         self.requester = requester
         self.verify = verify
 
-    def download(self, url, file_path=None, auth=None, retry=1, retry_wait=0, overwrite=False,
+    def download(self, url, file_path=None, auth=None, retry=3, retry_wait=0, overwrite=False,
                  headers=None):
 
         if file_path and not os.path.isabs(file_path):
@@ -130,10 +130,19 @@ class Downloader(object):
                 # the dest folder before
                 raise ConanException("Error, the file to download already exists: '%s'" % file_path)
 
+        return call_with_retry(self.output, retry, retry_wait, self._download_file, url, auth,
+                               headers, file_path)
+
+    def _download_file(self, url, auth, headers, file_path):
         t1 = time.time()
-        ret = bytearray()
-        response = call_with_retry(self.output, retry, retry_wait, self._download_file, url, auth, headers)
-        if not response.ok:  # Do not retry if not found or whatever controlled error
+
+        try:
+            response = self.requester.get(url, stream=True, verify=self.verify, auth=auth,
+                                          headers=headers)
+        except Exception as exc:
+            raise ConanException("Error downloading file %s: '%s'" % (url, exception_message_safe(exc)))
+
+        if not response.ok:
             if response.status_code == 404:
                 raise NotFoundException("Not found: %s" % url)
             elif response.status_code == 401:
@@ -141,61 +150,10 @@ class Downloader(object):
             raise ConanException("Error %d downloading file %s" % (response.status_code, url))
 
         try:
-            total_length = response.headers.get('content-length')
-
-            if total_length is None:  # no content length header
-                if not file_path:
-                    ret += response.content
-                else:
-                    total_length = len(response.content)
-                    progress = human_readable_progress(total_length, total_length)
-                    print_progress(self.output, 50, progress)
-                    save_append(file_path, response.content)
-            else:
-                total_length = int(total_length)
-                encoding = response.headers.get('content-encoding')
-                gzip = (encoding == "gzip")
-                # chunked can be a problem: https://www.greenbytes.de/tech/webdav/rfc2616.html#rfc.section.4.4
-                # It will not send content-length or should be ignored
-
-                def download_chunks(file_handler=None, ret_buffer=None):
-                    """Write to a buffer or to a file handler"""
-                    chunk_size = 1024 if not file_path else 1024 * 100
-                    download_size = 0
-                    last_progress = None
-                    for data in response.iter_content(chunk_size=chunk_size):
-                        download_size += len(data)
-                        if ret_buffer is not None:
-                            ret_buffer.extend(data)
-                        if file_handler is not None:
-                            file_handler.write(to_file_bytes(data))
-
-                        units = progress_units(download_size, total_length)
-                        progress = human_readable_progress(download_size, total_length)
-                        if last_progress != units:  # Avoid screen refresh if nothing has change
-                            if self.output:
-                                print_progress(self.output, units, progress)
-                            last_progress = units
-                    return download_size
-
-                if file_path:
-                    mkdir(os.path.dirname(file_path))
-                    with open(file_path, 'wb') as handle:
-                        dl_size = download_chunks(file_handler=handle)
-                else:
-                    dl_size = download_chunks(ret_buffer=ret)
-
-                if dl_size != total_length and not gzip:
-                    raise ConanException("Transfer interrupted before "
-                                         "complete: %s < %s" % (dl_size, total_length))
-
+            data = self._download_data(response, file_path)
             duration = time.time() - t1
             log_download(url, duration)
-
-            if not file_path:
-                return bytes(ret)
-            else:
-                return
+            return data
         except Exception as e:
             logger.debug(e.__class__)
             logger.debug(traceback.format_exc())
@@ -203,14 +161,62 @@ class Downloader(object):
             raise ConanConnectionError("Download failed, check server, possibly try again\n%s"
                                        % str(e))
 
-    def _download_file(self, url, auth, headers):
-        try:
-            response = self.requester.get(url, stream=True, verify=self.verify, auth=auth,
-                                          headers=headers)
-        except Exception as exc:
-            raise ConanException("Error downloading file %s: '%s'" % (url, exception_message_safe(exc)))
+    def _download_data(self, response, file_path):
+        ret = bytearray()
+        total_length = response.headers.get('content-length')
 
-        return response
+        if total_length is None:  # no content length header
+            if not file_path:
+                ret += response.content
+            else:
+                total_length = len(response.content)
+                progress = human_readable_progress(total_length, total_length)
+                print_progress(self.output, 50, progress)
+                save_append(file_path, response.content)
+        else:
+            total_length = int(total_length)
+            encoding = response.headers.get('content-encoding')
+            gzip = (encoding == "gzip")
+            # chunked can be a problem: https://www.greenbytes.de/tech/webdav/rfc2616.html#rfc.section.4.4
+            # It will not send content-length or should be ignored
+
+            def download_chunks(file_handler=None, ret_buffer=None):
+                """Write to a buffer or to a file handler"""
+                chunk_size = 1024 if not file_path else 1024 * 100
+                download_size = 0
+                last_progress = None
+                for data in response.iter_content(chunk_size):
+                    download_size += len(data)
+                    if ret_buffer is not None:
+                        ret_buffer.extend(data)
+                    if file_handler is not None:
+                        file_handler.write(to_file_bytes(data))
+
+                    units = progress_units(download_size, total_length)
+                    progress = human_readable_progress(download_size, total_length)
+                    if last_progress != units:  # Avoid screen refresh if nothing has change
+                        if self.output:
+                            print_progress(self.output, units, progress)
+                        last_progress = units
+                return download_size
+
+            if file_path:
+                mkdir(os.path.dirname(file_path))
+                with open(file_path, 'wb') as handle:
+                    dl_size = download_chunks(file_handler=handle)
+            else:
+                dl_size = download_chunks(ret_buffer=ret)
+
+            response.close()
+
+            if dl_size != total_length and not gzip:
+                raise ConanException("Transfer interrupted before "
+                                     "complete: %s < %s" % (dl_size, total_length))
+
+        if not file_path:
+            return bytes(ret)
+        else:
+            return
 
 
 def progress_units(progress, total):
diff --git a/conans/model/requires.py b/conans/model/requires.py
index ab8d03095..535771892 100644
--- a/conans/model/requires.py
+++ b/conans/model/requires.py
@@ -120,7 +120,7 @@ class Requirements(OrderedDict):
                 # update dependency
                 other_ref = other_req.conan_reference
                 if other_ref and other_ref != req.conan_reference:
-                    output.info("%s requirement %s overriden by %s to %s "
+                    output.info("%s requirement %s overridden by %s to %s "
                                 % (own_ref, req.conan_reference, down_ref or "your conanfile",
                                    other_ref))
                     req.conan_reference = other_ref

"overridden" is misspelled as "overriden"
This affects a few places, but most notably user messages output by conan.
diff --git a/conans/test/functional/client_certs_test.py b/conans/test/functional/client_certs_test.py
index a571e2ecf..377e860b4 100644
--- a/conans/test/functional/client_certs_test.py
+++ b/conans/test/functional/client_certs_test.py
@@ -2,7 +2,7 @@ import os
 import unittest
 
 from conans import tools
-from conans.test.utils.tools import TestClient, TestServer
+from conans.test.utils.tools import TestClient
 
 
 class ClientCertsTest(unittest.TestCase):
diff --git a/conans/test/functional/download_retries_test.py b/conans/test/functional/download_retries_test.py
new file mode 100644
index 000000000..58c2f91fc
--- /dev/null
+++ b/conans/test/functional/download_retries_test.py
@@ -0,0 +1,59 @@
+import unittest
+
+from conans.paths import CONANFILE
+from conans.test.utils.tools import TestClient, TestServer
+
+
+class DownloadRetriesTest(unittest.TestCase):
+
+    def test_recipe_download_retry(self):
+        test_server = TestServer()
+        client = TestClient(servers={"default": test_server},
+                            users={"default": [("lasote", "mypass")]})
+
+        conanfile = '''from conans import ConanFile
+class MyConanfile(ConanFile):
+    pass
+'''
+        client.save({CONANFILE: conanfile})
+        client.run("create . Pkg/0.1@lasote/stable")
+        client.run("upload '*' -c --all")
+
+        class Response(object):
+            ok = None
+            status_code = None
+            charset = None
+            headers = {}
+
+            def __init__(self, ok, status_code):
+                self.ok = ok
+                self.status_code = status_code
+
+            @property
+            def content(self):
+                if not self.ok:
+                    raise Exception("Bad boy")
+                else:
+                    return b'{"conanfile.py": "path/to/fake/file"}'
+
+            text = content
+
+        class BuggyRequester(object):
+
+            def __init__(self, *args, **kwargs):
+                pass
+
+            def get(self, *args, **kwargs):
+                if "path/to/fake/file" not in args[0]:
+                    return Response(True, 200)
+                else:
+                    return Response(False, 200)
+
+        # The buggy requester will cause a failure only downloading files, not in regular requests
+        client = TestClient(servers={"default": test_server},
+                            users={"default": [("lasote", "mypass")]},
+                            requester_class=BuggyRequester)
+        error = client.run("install Pkg/0.1@lasote/stable", ignore_error=True)
+        self.assertTrue(error)
+        self.assertEquals(str(client.out).count("Waiting 0 seconds to retry..."), 2)
+        self.assertEquals(str(client.out).count("ERROR: Error 200 downloading"), 3)
diff --git a/conans/test/functional/require_override_test.py b/conans/test/functional/require_override_test.py
index 1a7b209e4..fa7c259cf 100644
--- a/conans/test/functional/require_override_test.py
+++ b/conans/test/functional/require_override_test.py
@@ -75,5 +75,5 @@ class RequireOverrideTest(unittest.TestCase):
                                                       "libC/1.0@user/channel",
                                                       ("libA/1.0@user/channel", "override")])
             self.client.run("create . user/channel")
-            self.assertIn("libA/2.0@user/channel overriden by project/1.0@user/channel",
+            self.assertIn("libA/2.0@user/channel overridden by project/1.0@user/channel",
                           self.client.out)
diff --git a/conans/test/model/transitive_reqs_test.py b/conans/test/model/transitive_reqs_test.py
index b902dab53..7b0f08c53 100644
--- a/conans/test/model/transitive_reqs_test.py
+++ b/conans/test/model/transitive_reqs_test.py
@@ -465,7 +465,7 @@ class ChatConan(ConanFile):
         self.retriever.conan(bye_ref, bye_content2)
         deps_graph = self.root(chat_content)
 
-        self.assertIn("Hello/1.2@user/testing requirement Say/0.1@user/testing overriden by "
+        self.assertIn("Hello/1.2@user/testing requirement Say/0.1@user/testing overridden by "
                       "your conanfile to Say/0.2@user/testing", self.output)
         self.assertNotIn("Conflict", self.output)
         self.assertEqual(4, len(deps_graph.nodes))
@@ -1539,7 +1539,7 @@ class LibDConan(ConanFile):
 
         with self.assertRaisesRegexp(ConanException, "Conflict in LibB/0.1@user/testing"):
             self.root(self.consumer_content)
-        self.assertIn("LibB/0.1@user/testing requirement LibA/0.1@user/testing overriden by "
+        self.assertIn("LibB/0.1@user/testing requirement LibA/0.1@user/testing overridden by "
                       "LibD/0.1@user/testing to LibA/0.2@user/testing", str(self.output))
         self.assertEqual(1, str(self.output).count("LibA requirements()"))
         self.assertEqual(1, str(self.output).count("LibA configure()"))
diff --git a/conans/test/model/version_ranges_test.py b/conans/test/model/version_ranges_test.py
index 2c4838c19..e42560f05 100644
--- a/conans/test/model/version_ranges_test.py
+++ b/conans/test/model/version_ranges_test.py
@@ -242,9 +242,9 @@ class ChatConan(ConanFile):
         chat = _get_nodes(deps_graph, "Chat")[0]
         edges = {Edge(hello, say), Edge(chat, hello)}
         if override is not None:
-            self.assertIn("override", self.output)
+            self.assertIn("overridden", self.output)
         else:
-            self.assertNotIn("override", self.output)
+            self.assertNotIn("overridden", self.output)
         if override is False:
             edges = {Edge(hello, say), Edge(chat, say), Edge(chat, hello)}
 
diff --git a/conans/test/utils/tools.py b/conans/test/utils/tools.py
index 7c882db0d..f4ea46fcd 100644
--- a/conans/test/utils/tools.py
+++ b/conans/test/utils/tools.py
@@ -69,6 +69,9 @@ class TestingResponse(object):
     def __init__(self, test_response):
         self.test_response = test_response
 
+    def close(self):
+        pass  # Compatibility with close() method of a requests when stream=True
+
     @property
     def headers(self):
         return self.test_response.headers

pip install -e .[dev]
parse_log_pytest
requirements.txt
nose pytest
apt-get update apt-get install -y gcc pkg-config
3.6
conans/requirements.txt conans/requirements_server.txt conans/requirements_dev.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
astroid==1.6.6
attrs==22.2.0
beautifulsoup4==4.12.3
bottle==0.12.25
certifi==2021.5.30
charset-normalizer==2.0.12
codecov==2.1.13
colorama==0.3.9
-e git+https://github.com/conan-io/conan.git@f966d516452380918437888811bc833c804dac39#egg=conan
coverage==4.2
deprecation==2.0.7
distro==1.1.0
fasteners==0.19
future==0.16.0
idna==3.10
importlib-metadata==4.8.3
iniconfig==1.1.1
isort==5.10.1
lazy-object-proxy==1.7.1
mccabe==0.7.0
mock==1.3.0
node-semver==0.2.0
nose==1.3.7
packaging==21.3
parameterized==0.8.1
patch==1.16
pbr==6.1.1
pluggy==1.0.0
pluginbase==0.7
py==1.11.0
Pygments==2.14.0
PyJWT==1.7.1
pylint==1.8.4
pyparsing==3.1.4
pytest==7.0.1
PyYAML==3.13
requests==2.27.1
six==1.17.0
soupsieve==2.3.2.post1
tomli==1.2.3
typing_extensions==4.1.1
urllib3==1.26.20
waitress==2.0.0
WebOb==1.8.9
WebTest==2.0.35
wrapt==1.16.0
zipp==3.6.0

name: conan
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - astroid==1.6.6
      - attrs==22.2.0
      - beautifulsoup4==4.12.3
      - bottle==0.12.25
      - charset-normalizer==2.0.12
      - codecov==2.1.13
      - colorama==0.3.9
      - coverage==4.2
      - deprecation==2.0.7
      - distro==1.1.0
      - fasteners==0.19
      - future==0.16.0
      - idna==3.10
      - importlib-metadata==4.8.3
      - iniconfig==1.1.1
      - isort==5.10.1
      - lazy-object-proxy==1.7.1
      - mccabe==0.7.0
      - mock==1.3.0
      - node-semver==0.2.0
      - nose==1.3.7
      - packaging==21.3
      - parameterized==0.8.1
      - patch==1.16
      - pbr==6.1.1
      - pluggy==1.0.0
      - pluginbase==0.7
      - py==1.11.0
      - pygments==2.14.0
      - pyjwt==1.7.1
      - pylint==1.8.4
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pyyaml==3.13
      - requests==2.27.1
      - six==1.17.0
      - soupsieve==2.3.2.post1
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - waitress==2.0.0
      - webob==1.8.9
      - webtest==2.0.35
      - wrapt==1.16.0
      - zipp==3.6.0
prefix: /opt/conda/envs/conan

conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_diamond_conflict_solved conans/test/model/transitive_reqs_test.py::ConanRequirementsOptimizerTest::test_expand_requirements
conans/test/functional/download_retries_test.py::DownloadRetriesTest::test_recipe_download_retry conans/test/functional/require_override_test.py::RequireOverrideTest::test_override
conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_basic conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_basic_option conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_basic_transitive_option conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_conditional conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_conditional_diamond conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_dep_requires_clear conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_diamond_conflict conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_diamond_conflict_error conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_diamond_conflict_options conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_diamond_conflict_options_solved conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_diamond_no_conflict conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_diamond_no_conflict_options conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_propagate_indirect_options conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_remove_build_requires conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_remove_two_build_requires conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_simple_override conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_transitive conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_transitive_diamond_private conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_transitive_pattern_options conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_transitive_private conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_transitive_two_levels conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_transitive_two_levels_options conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_transitive_two_levels_wrong_options conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_version_requires2_change conans/test/model/transitive_reqs_test.py::ConanRequirementsTest::test_version_requires_change conans/test/model/transitive_reqs_test.py::ConanRequirementsOptimizerTest::test_avoid_duplicate_expansion conans/test/model/transitive_reqs_test.py::ConanRequirementsOptimizerTest::test_expand_conflict_options conans/test/model/transitive_reqs_test.py::ConanRequirementsOptimizerTest::test_expand_options conans/test/model/transitive_reqs_test.py::ConanRequirementsOptimizerTest::test_expand_requirements_direct conans/test/model/transitive_reqs_test.py::CoreSettingsTest::test_basic conans/test/model/transitive_reqs_test.py::CoreSettingsTest::test_config conans/test/model/transitive_reqs_test.py::CoreSettingsTest::test_config_remove conans/test/model/transitive_reqs_test.py::CoreSettingsTest::test_config_remove2 conans/test/model/transitive_reqs_test.py::CoreSettingsTest::test_errors conans/test/model/transitive_reqs_test.py::CoreSettingsTest::test_new_configure conans/test/model/transitive_reqs_test.py::CoreSettingsTest::test_transitive_two_levels_options conans/test/model/version_ranges_test.py::VersionRangesTest::test_local_basic conans/test/model/version_ranges_test.py::VersionRangesTest::test_remote_basic
conans/client/rest/rest_client_v1.py conans/client/rest/uploader_downloader.py conans/client/rest/rest_client_common.py conans/model/requires.py conans/client/graph/graph_builder.py
conans/client/rest/rest_client_v1.py conans/client/rest/uploader_downloader.py conans/client/rest/rest_client_common.py conans/model/requires.py conans/client/graph/graph_builder.py