diff --git a/pyproject.toml b/pyproject.toml index 08f90c49c..68f7589ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68.0", "versioneer>=0.29", "wheel"] +requires = ["setuptools>=75.0", "versioneer[toml]==0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -16,7 +16,7 @@ dependencies = [ 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.32', # latest as at 7/31/23 'urllib3>=2.2.2,<3', - 'typing_extensions>=4.0.1', + 'typing_extensions>=4.0', ] requires-python = ">=3.9" classifiers = [ @@ -38,6 +38,7 @@ test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytes [tool.black] line-length = 120 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +force-exclude = "tableauserverclient/bin/*" [tool.mypy] check_untyped_defs = false @@ -50,7 +51,15 @@ show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true implicit_optional = true +exclude = ['/bin/'] [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" + +[tool.versioneer] +VCS = "git" +style = "pep440-pre" +versionfile_source = "tableauserverclient/bin/_version.py" +versionfile_build = "tableauserverclient/bin/_version.py" +tag_prefix = "v" diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py index 8408f67ee..8c02fefff 100644 --- a/samples/create_extract_task.py +++ b/samples/create_extract_task.py @@ -29,7 +29,9 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample: - # This sample has no additional options, yet. If you add some, please add them here + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("--incremental", default=False) args = parser.parse_args() @@ -45,6 +47,7 @@ def main(): # Monthly Schedule # This schedule will run on the 15th of every month at 11:30PM monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + print(monthly_interval) monthly_schedule = TSC.ScheduleItem( None, None, @@ -53,18 +56,20 @@ def main(): monthly_interval, ) - # Default to using first workbook found in server - all_workbook_items, pagination_item = server.workbooks.get() - my_workbook: TSC.WorkbookItem = all_workbook_items[0] + my_workbook: TSC.WorkbookItem = server.workbooks.get_by_id(args.resource_id) target_item = TSC.Target( my_workbook.id, # the id of the workbook or datasource "workbook", # alternatively can be "datasource" ) - extract_item = TSC.TaskItem( + refresh_type = "FullRefresh" + if args.incremental: + refresh_type = "Incremental" + + scheduled_extract_item = TSC.TaskItem( None, - "FullRefresh", + refresh_type, None, None, None, @@ -74,7 +79,7 @@ def main(): ) try: - response = server.tasks.create(extract_item) + response = server.tasks.create(scheduled_extract_item) print(response) except Exception as e: print(e) diff --git a/samples/extracts.py b/samples/extracts.py index c0dd885bc..8e7a66aac 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -25,8 +25,11 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--delete") - parser.add_argument("--create") + parser.add_argument("--create", action="store_true") + parser.add_argument("--delete", action="store_true") + parser.add_argument("--refresh", action="store_true") + parser.add_argument("--workbook", required=False) + parser.add_argument("--datasource", required=False) args = parser.parse_args() # Set logging level based on user input, or error by default @@ -39,20 +42,45 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") - print([workbook.name for workbook in all_workbooks]) - if all_workbooks: - # Pick one workbook from the list - wb = all_workbooks[3] + wb = None + ds = None + if args.workbook: + wb = server.workbooks.get_by_id(args.workbook) + if wb is None: + raise ValueError(f"Workbook not found for id {args.workbook}") + elif args.datasource: + ds = server.datasources.get_by_id(args.datasource) + if ds is None: + raise ValueError(f"Datasource not found for id {args.datasource}") + else: + # Gets all workbook items + all_workbooks, pagination_item = server.workbooks.get() + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick one workbook from the list + wb = all_workbooks[3] if args.create: print("create extract on wb ", wb.name) extract_job = server.workbooks.create_extract(wb, includeAll=True) print(extract_job) + if args.refresh: + extract_job = None + if ds is not None: + print(f"refresh extract on datasource {ds.name}") + extract_job = server.datasources.refresh(ds, includeAll=True, incremental=True) + elif wb is not None: + print(f"refresh extract on workbook {wb.name}") + extract_job = server.workbooks.refresh(wb) + else: + print("no content item selected to refresh") + + print(extract_job) + if args.delete: print("delete extract on wb ", wb.name) jj = server.workbooks.delete_extract(wb) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index d31978c0f..077ddaddd 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -36,9 +36,16 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample + group = parser.add_mutually_exclusive_group(required=False) + group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails") + group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails") + + parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook") parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") + parser.add_argument("--project", help="Project within which to publish the workbook") + parser.add_argument("--show-tabs", help="Publish workbooks with tabs displayed", action="store_true") args = parser.parse_args() @@ -48,11 +55,22 @@ def main(): # Step 1: Sign in to server. tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): - # Step 2: Get all the projects on server, then look for the default one. - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Step2: Retrieve the project id, if a project name was passed + if args.project is not None: + req_options = TSC.RequestOptions() + req_options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) + ) + projects = list(TSC.Pager(server.projects, req_options)) + if len(projects) > 1: + raise ValueError("The project name is not unique") + project_id = projects[0].id + else: + # Get all the projects on server, then look for the default one. + all_projects, pagination_item = server.projects.get() + project_id = next((project for project in all_projects if project.is_default()), None).id connection1 = ConnectionItem() connection1.server_address = "mssql.test.com" @@ -67,10 +85,16 @@ def main(): all_connections.append(connection1) all_connections.append(connection2) - # Step 3: If default project is found, form a new workbook item and publish. + # Step 3: Form a new workbook item and publish. overwrite_true = TSC.Server.PublishMode.Overwrite - if default_project is not None: - new_workbook = TSC.WorkbookItem(default_project.id) + if project_id is not None: + new_workbook = TSC.WorkbookItem( + project_id=project_id, + name=args.workbook_name, + show_tabs=args.show_tabs, + thumbnails_user_id=args.thumbnails_user_id, + thumbnails_group_id=args.thumbnails_group_id, + ) if args.as_job: new_job = server.workbooks.publish( new_workbook, @@ -92,7 +116,7 @@ def main(): ) print(f"Workbook published. ID: {new_workbook.id}") else: - error = "The default project could not be found." + error = "The destination project could not be found." raise LookupError(error) diff --git a/samples/refresh.py b/samples/refresh.py index d3e49ed24..99242fcdb 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -27,6 +27,8 @@ def main(): # Options specific to this sample parser.add_argument("resource_type", choices=["workbook", "datasource"]) parser.add_argument("resource_id") + parser.add_argument("--incremental") + parser.add_argument("--synchronous") args = parser.parse_args() @@ -34,27 +36,42 @@ def main(): logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) + refresh_type = "FullRefresh" + incremental = False + if args.incremental: + refresh_type = "Incremental" + incremental = True + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): if args.resource_type == "workbook": # Get the workbook by its Id to make sure it exists resource = server.workbooks.get_by_id(args.resource_id) + print(resource) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - job = server.workbooks.refresh(args.resource_id) + job = server.workbooks.refresh(args.resource_id, incremental=incremental) else: # Get the datasource by its Id to make sure it exists resource = server.datasources.get_by_id(args.resource_id) + print(resource) + + # server.datasources.create_extract(resource) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - job = server.datasources.refresh(resource) + job = server.datasources.refresh(resource, incremental=incremental) # by default runs as a sync task, - print(f"Update job posted (ID: {job.id})") - print("Waiting for job...") - # `wait_for_job` will throw if the job isn't executed successfully - job = server.jobs.wait_for_job(job) - print("Job finished succesfully") + print(f"{refresh_type} job posted (ID: {job.id})") + if args.synchronous: + # equivalent to tabcmd --synchnronous: wait for the job to complete + try: + # `wait_for_job` will throw if the job isn't executed successfully + print("Waiting for job...") + server.jobs.wait_for_job(job) + print("Job finished succesfully") + except Exception as e: + print(f"Job failed! {e}") if __name__ == "__main__": diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a551fdb6a..000000000 --- a/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. -# versioneer does not support pyproject.toml -[versioneer] -VCS = git -style = pep440-pre -versionfile_source = tableauserverclient/_version.py -versionfile_build = tableauserverclient/_version.py -tag_prefix = v diff --git a/setup.py b/setup.py index dfd43ae8a..bdce51f2e 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,6 @@ import versioneer from setuptools import setup -""" -once versioneer 0.25 gets released, we can move this from setup.cfg to pyproject.toml -[tool.versioneer] -VCS = "git" -style = "pep440-pre" -versionfile_source = "tableauserverclient/_version.py" -versionfile_build = "tableauserverclient/_version.py" -tag_prefix = "v" -""" setup( version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index e0a7abb64..39f8267a8 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,4 +1,4 @@ -from tableauserverclient._version import get_versions +from tableauserverclient.bin._version import get_versions from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from tableauserverclient.models import ( BackgroundJobItem, @@ -133,3 +133,7 @@ "WeeklyInterval", "WorkbookItem", ] + +from .bin import _version + +__version__ = _version.get_versions()["version"] diff --git a/tableauserverclient/_version.py b/tableauserverclient/bin/_version.py similarity index 52% rename from tableauserverclient/_version.py rename to tableauserverclient/bin/_version.py index 79dbed1d8..f23819e86 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/bin/_version.py @@ -1,11 +1,13 @@ + # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) +# This file is released into the public domain. +# Generated by versioneer-0.29 +# https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -14,9 +16,11 @@ import re import subprocess import sys +from typing import Any, Callable, Dict, List, Optional, Tuple +import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -32,14 +36,21 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py cfg = VersioneerConfig() cfg.VCS = "git" - cfg.style = "pep440" + cfg.style = "pep440-pre" cfg.tag_prefix = "v" cfg.parentdir_prefix = "None" cfg.versionfile_source = "tableauserverclient/_version.py" @@ -51,41 +62,50 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} # type: ignore -HANDLERS = {} - +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f - return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) - p = None - for c in commands: + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: try: - dispcmd = str([c] + args) + dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen( - [c] + args, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr else None), - ) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -94,20 +114,22 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}") + print("unable to find command, tried %s" % (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode + return None, process.returncode + return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -116,61 +138,64 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): """ rootdirs = [] - for i in range(3): + for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level + return {"version": dirname[len(parentdir_prefix):], + "full-revisionid": None, + "dirty": False, "error": None, "date": None} + rootdirs.append(root) + root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % + (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: - f = open(versionfile_abs) - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) except OSError: pass return keywords @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -187,7 +212,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +221,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = {r for r in refs if re.search(r'\d', r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -204,30 +229,33 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] + r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue if verbose: print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } + return {"version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": None, + "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } + return {"version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -238,7 +266,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -246,33 +282,57 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command( - GITS, - [ - "describe", - "--tags", - "--dirty", - "--always", - "--long", - "--match", - "%s*" % tag_prefix, - ], - cwd=root, - ) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out @@ -281,16 +341,17 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] + git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) + mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out + # unparsable. Maybe git-describe is misbehaving? + pieces["error"] = ("unable to parse git-describe output: '%s'" + % describe_out) return pieces # tag @@ -299,12 +360,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( - full_tag, - tag_prefix, - ) + pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" + % (full_tag, tag_prefix)) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] + pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -315,24 +374,27 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -350,29 +412,78 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%d" % (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -399,12 +510,41 @@ def render_pep440_post(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -421,7 +561,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -441,7 +581,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -461,26 +601,28 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } + return {"version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": @@ -490,16 +632,12 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } + return {"version": rendered, "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], "error": None, + "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -510,7 +648,8 @@ def get_versions(): verbose = cfg.verbose try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) + return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, + verbose) except NotThisMethod: pass @@ -519,16 +658,13 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split("/"): + for _ in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to find root of source tree", + "date": None} try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) @@ -542,10 +678,6 @@ def get_versions(): except NotThisMethod: pass - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", "date": None} diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index bb2cbbba9..aaa2f1bed 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -2,11 +2,27 @@ class ConnectionCredentials: - """Connection Credentials for Workbooks and Datasources publish request. + """ + Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets as soon as possible after use to avoid them hanging around in memory. + Parameters + ---------- + name: str + The username for the connection. + + password: str + The password used for the connection. + + embed: bool, default True + Determines whether to embed the password (True) for the workbook or data source connection or not (False). + + oauth: bool, default False + Determines whether to use OAuth for the connection (True) or not (False). + For more information see: https://help.tableau.com/current/server/en-us/protected_auth.htm + """ def __init__(self, name, password, embed=True, oauth=False): diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 937e43481..e68958c3b 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -9,6 +9,44 @@ class ConnectionItem: + """ + Corresponds to workbook and data source connections. + + Attributes + ---------- + datasource_id: str + The identifier of the data source. + + datasource_name: str + The name of the data source. + + id: str + The identifier of the connection. + + connection_type: str + The type of connection. + + username: str + The username for the connection. (see ConnectionCredentials) + + password: str + The password used for the connection. (see ConnectionCredentials) + + embed_password: bool + Determines whether to embed the password (True) for the workbook or data source connection or not (False). (see ConnectionCredentials) + + server_address: str + The server address for the connection. + + server_port: str + The port used for the connection. + + connection_credentials: ConnectionCredentials + The Connection Credentials object containing authentication details for + the connection. Replaces username/password/embed_password when + publishing a flow, document or workbook file in the request body. + """ + def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index a0c0a9844..5cafe469c 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -5,14 +5,58 @@ from typing import Callable, Optional from collections.abc import Iterator -from .exceptions import UnpopulatedPropertyError -from .user_item import UserItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem -from ..datetime_helpers import parse_datetime +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.datetime_helpers import parse_datetime class CustomViewItem: + """ + Represents a Custom View item on Tableau Server. + + Parameters + ---------- + id : Optional[str] + The ID of the Custom View item. + + name : Optional[str] + The name of the Custom View item. + + Attributes + ---------- + content_url : Optional[str] + The content URL of the Custom View item. + + created_at : Optional[datetime] + The date and time the Custom View item was created. + + image: bytes + The image of the Custom View item. Must be populated first. + + pdf: bytes + The PDF of the Custom View item. Must be populated first. + + csv: Iterator[bytes] + The CSV of the Custom View item. Must be populated first. + + shared : Optional[bool] + Whether the Custom View item is shared. + + updated_at : Optional[datetime] + The date and time the Custom View item was last updated. + + owner : Optional[UserItem] + The id of the owner of the Custom View item. + + workbook : Optional[WorkbookItem] + The id of the workbook the Custom View item belongs to. + + view : Optional[ViewItem] + The id of the view the Custom View item belongs to. + """ + def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 9bcad5e89..0083776bb 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import Iterable, Optional from defusedxml.ElementTree import fromstring @@ -15,6 +15,51 @@ class FlowItem: + """ + Represents a Tableau Flow item. + + Parameters + ---------- + project_id: str + The ID of the project that the flow belongs to. + + name: Optional[str] + The name of the flow. + + Attributes + ---------- + connections: Iterable[ConnectionItem] + The connections associated with the flow. This property is not populated + by default and must be populated by calling the `populate_connections` + method. + + created_at: Optional[datetime.datetime] + The date and time when the flow was created. + + description: Optional[str] + The description of the flow. + + dqws: Iterable[DQWItem] + The data quality warnings associated with the flow. This property is not + populated by default and must be populated by calling the `populate_dqws` + method. + + id: Optional[str] + The ID of the flow. + + name: Optional[str] + The name of the flow. + + owner_id: Optional[str] + The ID of the user who owns the flow. + + project_name: Optional[str] + The name of the project that the flow belongs to. + + tags: set[str] + The tags associated with the flow. + """ + def __repr__(self): return " None: self.tags: set[str] = set() self.description: Optional[str] = None - self._connections: Optional[ConnectionItem] = None - self._permissions: Optional[Permission] = None - self._data_quality_warnings: Optional[DQWItem] = None + self._connections: Optional[Iterable[ConnectionItem]] = None + self._permissions: Optional[Iterable[Permission]] = None + self._data_quality_warnings: Optional[Iterable[DQWItem]] = None @property def connections(self): diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6871f8b16..0afd5582c 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -12,6 +12,46 @@ class GroupItem: + """ + The GroupItem class contains the attributes for the group resources on + Tableau Server. The GroupItem class defines the information you can request + or query from Tableau Server. The class members correspond to the attributes + of a server request or response payload. + + Parameters + ---------- + name: str + The name of the group. + + domain_name: str + The name of the Active Directory domain ("local" if local authentication is used). + + Properties + ---------- + users: Pager[UserItem] + The users in the group. Must be populated with a call to `populate_users()`. + + id: str + The unique identifier for the group. + + minimum_site_role: str + The minimum site role for users in the group. Use the `UserItem.Roles` enum. + Users in the group cannot have their site role set lower than this value. + + license_mode: str + The mode defining when to apply licenses for group members. When the + mode is onLogin, a license is granted for each group member when they + login to a site. When the mode is onSync, a license is granted for group + members each time the domain is synced. + + Examples + -------- + >>> # Create a new group item + >>> newgroup = TSC.GroupItem('My Group') + + + """ + tag_name: str = "group" class LicenseMode: diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc7cd5811..6286275c5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -8,6 +8,71 @@ class JobItem: + """ + Using the TSC library, you can get information about an asynchronous process + (or job) on the server. These jobs can be created when Tableau runs certain + tasks that could be long running, such as importing or synchronizing users + from Active Directory, or running an extract refresh. For example, the REST + API methods to create or update groups, to run an extract refresh task, or + to publish workbooks can take an asJob parameter (asJob-true) that creates a + background process (the job) to complete the call. Information about the + asynchronous job is returned from the method. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The job properties are defined in the JobItem class. The class corresponds + to the properties for jobs you can access using the Tableau Server REST API. + The job methods are based upon the endpoints for jobs in the REST API and + operate on the JobItem class. + + Parameters + ---------- + id_ : str + The identifier of the job. + + job_type : str + The type of job. + + progress : str + The progress of the job. + + created_at : datetime.datetime + The date and time the job was created. + + started_at : Optional[datetime.datetime] + The date and time the job was started. + + completed_at : Optional[datetime.datetime] + The date and time the job was completed. + + finish_code : int + The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. + + notes : Optional[list[str]] + Contains detailed notes about the job. + + mode : Optional[str] + + workbook_id : Optional[str] + The identifier of the workbook associated with the job. + + datasource_id : Optional[str] + The identifier of the datasource associated with the job. + + flow_run : Optional[FlowRunItem] + The flow run associated with the job. + + updated_at : Optional[datetime.datetime] + The date and time the job was last updated. + + workbook_name : Optional[str] + The name of the workbook associated with the job. + + datasource_name : Optional[str] + The name of the datasource associated with the job. + """ + class FinishCode: """ Status codes as documented on diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 48f27c60c..9be1196ba 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,6 +9,44 @@ class ProjectItem: + """ + The project resources for Tableau are defined in the ProjectItem class. The + class corresponds to the project resources you can access using the Tableau + Server REST API. + + Parameters + ---------- + name : str + Name of the project. + + description : str + Description of the project. + + content_permissions : str + Sets or shows the permissions for the content in the project. The + options are either LockedToProject, ManagedByOwner or + LockedToProjectWithoutNested. + + parent_id : str + The id of the parent project. Use this option to create project + hierarchies. For information about managing projects, project + hierarchies, and permissions, see + https://help.tableau.com/current/server/en-us/projects.htm + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Attributes + ---------- + id : str + The unique identifier for the project. + + owner_id : str + The unique identifier for the UserItem owner of the project. + + """ + ERROR_MSG = "Project item must be populated with permissions first." class ContentPermissions: @@ -174,7 +212,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = f"_default_{content_type}_permissions".lower() setattr( self, attr, diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index fa6f782ba..8d2492aed 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -9,6 +9,36 @@ class TaskItem: + """ + Represents a task item in Tableau Server. To create new tasks, see Schedules. + + Parameters + ---------- + id_ : str + The ID of the task. + + task_type : str + Type of task. See TaskItem.Type for possible values. + + priority : int + The priority of the task on the server. + + consecutive_failed_count : int + The number of consecutive times the task has failed. + + schedule_id : str, optional + The ID of the schedule that the task is associated with. + + schedule_item : ScheduleItem, optional + The schedule item that the task is associated with. + + last_run_at : datetime, optional + The last time the task was run. + + target : Target, optional + The target of the task. This can be a workbook or a datasource. + """ + class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index dc5f37a48..88cec7328 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -7,12 +7,64 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .exceptions import UnpopulatedPropertyError -from .permissions_item import PermissionsRule -from .tag_item import TagItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.tag_item import TagItem class ViewItem: + """ + Contains the members or attributes for the view resources on Tableau Server. + The ViewItem class defines the information you can request or query from + Tableau Server. The class members correspond to the attributes of a server + request or response payload. + + Attributes + ---------- + content_url: Optional[str], default None + The name of the view as it would appear in a URL. + + created_at: Optional[datetime], default None + The date and time when the view was created. + + id: Optional[str], default None + The unique identifier for the view. + + image: Optional[Callable[[], bytes]], default None + The image of the view. You must first call the `views.populate_image` + method to access the image. + + name: Optional[str], default None + The name of the view. + + owner_id: Optional[str], default None + The ID for the owner of the view. + + pdf: Optional[Callable[[], bytes]], default None + The PDF of the view. You must first call the `views.populate_pdf` + method to access the PDF. + + preview_image: Optional[Callable[[], bytes]], default None + The preview image of the view. You must first call the + `views.populate_preview_image` method to access the preview image. + + project_id: Optional[str], default None + The ID for the project that contains the view. + + tags: set[str], default set() + The tags associated with the view. + + total_views: Optional[int], default None + The total number of views for the view. + + updated_at: Optional[datetime], default None + The date and time when the view was last updated. + + workbook_id: Optional[str], default None + The ID for the workbook that contains the view. + + """ + def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 98d821fb4..8b551dea4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -14,6 +14,39 @@ def _parse_event(events): class WebhookItem: + """ + The WebhookItem represents the webhook resources on Tableau Server or + Tableau Cloud. This is the information that can be sent or returned in + response to a REST API request for webhooks. + + Attributes + ---------- + id : Optional[str] + The identifier (luid) for the webhook. You need this value to query a + specific webhook with the get_by_id method or to delete a webhook with + the delete method. + + name : Optional[str] + The name of the webhook. You must specify this when you create an + instance of the WebhookItem. + + url : Optional[str] + The destination URL for the webhook. The webhook destination URL must + be https and have a valid certificate. You must specify this when you + create an instance of the WebhookItem. + + event : Optional[str] + The name of the Tableau event that triggers your webhook.This is either + api-event-name or webhook-source-api-event-name: one of these is + required to create an instance of the WebhookItem. We recommend using + the api-event-name. The event name must be one of the supported events + listed in the Trigger Events table. + https://help.tableau.com/current/developer/webhooks/en-us/docs/webhooks-events-payload.html + + owner_id : Optional[str] + The identifier (luid) of the user who owns the webhook. + """ + def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 776d041e3..32ab413a4 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -99,7 +99,14 @@ class as arguments. The workbook item specifies the project. >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') """ - def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: + def __init__( + self, + project_id: Optional[str] = None, + name: Optional[str] = None, + show_tabs: bool = False, + thumbnails_user_id: Optional[str] = None, + thumbnails_group_id: Optional[str] = None, + ) -> None: self._connections = None self._content_url = None self._webpage_url = None @@ -130,6 +137,8 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, } self.data_freshness_policy = None self._permissions = None + self.thumbnails_user_id = thumbnails_user_id + self.thumbnails_group_id = thumbnails_group_id return None @@ -275,6 +284,22 @@ def revisions(self) -> list[RevisionItem]: raise UnpopulatedPropertyError(error) return self._revisions() + @property + def thumbnails_user_id(self) -> Optional[str]: + return self._thumbnails_user_id + + @thumbnails_user_id.setter + def thumbnails_user_id(self, value: str): + self._thumbnails_user_id = value + + @property + def thumbnails_group_id(self) -> Optional[str]: + return self._thumbnails_group_id + + @thumbnails_group_id.setter + def thumbnails_group_id(self, value: str): + self._thumbnails_group_id = value + def _set_connections(self, connections): self._connections = connections diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 4211bb7ea..35dfa5d78 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -84,9 +84,10 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) + site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) - self.parent_srv._set_auth(site_id, user_id, auth_token) + self.parent_srv._set_auth(site_id, user_id, auth_token, site_url) logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @@ -155,9 +156,10 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) + site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) - self.parent_srv._set_auth(site_id, user_id, auth_token) + self.parent_srv._set_auth(site_id, user_id, auth_token, site_url) logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index b02b05d78..8d78dca7a 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -3,7 +3,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config @@ -21,6 +21,9 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.query import QuerySet + """ Get a list of custom views on a site get the details of a custom view @@ -51,19 +54,31 @@ def baseurl(self) -> str: def expurl(self) -> str: return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews" - """ - If the request has no filter parameters: Administrators will see all custom views. - Other users will see only custom views that they own. - If the filter parameters include ownerId: Users will see only custom views that they own. - If the filter parameters include viewId and/or workbookId, and don't include ownerId: - Users will see those custom views that they have Write and WebAuthoring permissions for. - If site user visibility is not set to Limited, the Users will see those custom views that are "public", - meaning the value of their shared attribute is true. - If site user visibility is set to Limited, ???? - """ - @api(version="3.18") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: + """ + Get a list of custom views on a site. + + If the request has no filter parameters: Administrators will see all custom views. + Other users will see only custom views that they own. + If the filter parameters include ownerId: Users will see only custom views that they own. + If the filter parameters include viewId and/or workbookId, and don't include ownerId: + Users will see those custom views that they have Write and WebAuthoring permissions for. + If site user visibility is not set to Limited, the Users will see those custom views that are "public", + meaning the value of their shared attribute is true. + If site user visibility is set to Limited, ???? + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#list_custom_views + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request, by default None + + Returns + ------- + tuple[list[CustomViewItem], PaginationItem] + """ logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -73,6 +88,19 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Cust @api(version="3.18") def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: + """ + Get the details of a specific custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view + + Parameters + ---------- + view_id : str + + Returns + ------- + Optional[CustomViewItem] + """ if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) @@ -83,6 +111,27 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: @api(version="3.18") def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + """ + Populate the image of a custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view_image + + Parameters + ---------- + view_item : CustomViewItem + + req_options : ImageRequestOptions, optional + Options to customize the image returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the view_item is missing an ID + """ if not view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -101,6 +150,26 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag @api(version="3.23") def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + """ + Populate the PDF of a custom view. + + Parameters + ---------- + custom_view_item : CustomViewItem + The custom view item to populate the PDF for. + + req_options : PDFRequestOptions, optional + Options to customize the PDF returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the custom view item is missing an ID + """ if not custom_view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -121,6 +190,26 @@ def _get_custom_view_pdf( @api(version="3.23") def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + """ + Populate the CSV of a custom view. + + Parameters + ---------- + custom_view_item : CustomViewItem + The custom view item to populate the CSV for. + + req_options : CSVRequestOptions, optional + Options to customize the CSV returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the custom view item is missing an ID + """ if not custom_view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -141,6 +230,21 @@ def _get_custom_view_csv( @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: + """ + Updates the name, owner, or shared status of a custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_custom_view + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to update. + + Returns + ------- + Optional[CustomViewItem] + The updated custom view item. + """ if not view_item.id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) @@ -158,6 +262,25 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: # Delete 1 view by id @api(version="3.19") def delete(self, view_id: str) -> None: + """ + Deletes a single custom view by ID. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_custom_view + + Parameters + ---------- + view_id : str + The ID of the custom view to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the view_id is not provided. + """ if not view_id: error = "Custom View ID undefined." raise ValueError(error) @@ -167,6 +290,27 @@ def delete(self, view_id: str) -> None: @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: + """ + Download the definition of a custom view as json. The file parameter can + be a file path or a file object. If a file path is provided, the file + will be written to that location. If a file object is provided, the file + will be written to that object. + + May contain sensitive information. + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to download. + + file : PathOrFileW + The file path or file object to write the custom view to. + + Returns + ------- + PathOrFileW + The file path or file object that the custom view was written to. + """ url = f"{self.expurl}/{view_item.id}/content" server_response = self.get_request(url) if isinstance(file, io_types_w): @@ -180,6 +324,25 @@ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @api(version="3.21") def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]: + """ + Publish a custom view to Tableau Server. The file parameter can be a + file path or a file object. If a file path is provided, the file will be + read from that location. If a file object is provided, the file will be + read from that object. + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to publish. + + file : PathOrFileR + The file path or file object to read the custom view from. + + Returns + ------- + Optional[CustomViewItem] + The published custom view item. + """ url = self.expurl if isinstance(file, io_types_r): size = get_file_object_size(file) @@ -207,3 +370,25 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust server_response = self.post_request(url, xml_request, content_type) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> "QuerySet[CustomViewItem]": + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + view_id=... + workbook_id=... + owner_id=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6bd809c28..1f00af570 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -102,10 +102,15 @@ def connections_fetcher(): datasource_item._set_connections(connections_fetcher) logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") - def _get_datasource_connections(self, datasource_item, req_options=None): + def _get_datasource_connections( + self, datasource_item: DatasourceItem, req_options: Optional[RequestOptions] = None + ) -> list[ConnectionItem]: url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + for connection in connections: + connection._datasource_id = datasource_item.id + connection._datasource_name = datasource_item.name return connections # Delete 1 datasource by id @@ -182,11 +187,11 @@ def update_connection( return connection @api(version="2.8") - def refresh(self, datasource_item: DatasourceItem) -> JobItem: + def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + refresh_req = RequestFactory.Task.refresh_req(incremental) + server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -255,13 +260,12 @@ def publish( else: raise TypeError("file should be a filepath or file object.") - if not mode or not hasattr(self.parent_srv.PublishMode, mode): - error = "Invalid mode defined." - raise ValueError(error) - # Construct the url with the defined mode url = f"{self.baseurl}?datasourceType={file_extension}" - if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: + if not mode or not hasattr(self.parent_srv.PublishMode, mode): + error = f"Invalid mode defined: {mode}" + raise ValueError(error) + else: url += f"&{mode.lower()}=true" if as_job: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7eb5dc3ba..42c9d4c1e 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -66,6 +66,25 @@ def baseurl(self) -> str: # Get all flows @api(version="3.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: + """ + Get all flows on site. Returns a tuple of all flow items and pagination item. + This method is paginated, and returns one page of items per call. The + request options can be used to specify the page number, page size, as + well as sorting and filtering options. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flows_for_site + + Parameters + ---------- + req_options: Optional[RequestOptions] + An optional Request Options object that can be used to specify + sorting, filtering, and pagination options. + + Returns + ------- + tuple[list[FlowItem], PaginationItem] + A tuple of a list of flow items and a pagination item. + """ logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -76,6 +95,21 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Flow # Get 1 flow by id @api(version="3.3") def get_by_id(self, flow_id: str) -> FlowItem: + """ + Get a single flow by id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow + + Parameters + ---------- + flow_id: str + The id of the flow to retrieve. + + Returns + ------- + FlowItem + The flow item that was retrieved. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -87,6 +121,27 @@ def get_by_id(self, flow_id: str) -> FlowItem: # Populate flow item's connections @api(version="3.3") def populate_connections(self, flow_item: FlowItem) -> None: + """ + Populate the connections for a flow item. This method will make a + request to the Tableau Server to get the connections associated with + the flow item and populate the connections property of the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_connections + + Parameters + ---------- + flow_item: FlowItem + The flow item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the flow item does not have an ID. + """ if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -106,6 +161,25 @@ def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions # Delete 1 flow by id @api(version="3.3") def delete(self, flow_id: str) -> None: + """ + Delete a single flow by id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow + + Parameters + ---------- + flow_id: str + The id of the flow to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the flow_id is not defined. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -116,6 +190,35 @@ def delete(self, flow_id: str) -> None: # Download 1 flow by id @api(version="3.3") def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> PathOrFileW: + """ + Download a single flow by id. The flow will be downloaded to the + specified file path. If no file path is specified, the flow will be + downloaded to the current working directory. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#download_flow + + Parameters + ---------- + flow_id: str + The id of the flow to download. + + filepath: Optional[PathOrFileW] + The file path to download the flow to. This can be a file path or + a file object. If a file object is passed, the flow will be written + to the file object. If a file path is passed, the flow will be + written to the file path. If no file path is specified, the flow + will be downloaded to the current working directory. + + Returns + ------- + PathOrFileW + The file path or file object that the flow was downloaded to. + + Raises + ------ + ValueError + If the flow_id is not defined. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -144,6 +247,21 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path # Update flow @api(version="3.3") def update(self, flow_item: FlowItem) -> FlowItem: + """ + Updates the flow owner, project, description, and/or tags. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow + + Parameters + ---------- + flow_item: FlowItem + The flow item to update. + + Returns + ------- + FlowItem + The updated flow item. + """ if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -161,6 +279,25 @@ def update(self, flow_item: FlowItem) -> FlowItem: # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: + """ + Update a connection item for a flow item. This method will update the + connection details for the connection item associated with the flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow_connection + + Parameters + ---------- + flow_item: FlowItem + The flow item that the connection is associated with. + + connection_item: ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) @@ -172,6 +309,21 @@ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: + """ + Runs the flow to refresh the data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#run_flow_now + + Parameters + ---------- + flow_item: FlowItem + The flow item to refresh. + + Returns + ------- + JobItem + The job item that was created to refresh the flow. + """ url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) @@ -183,6 +335,35 @@ def refresh(self, flow_item: FlowItem) -> JobItem: def publish( self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: + """ + Publishes a flow to the Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#publish_flow + + Parameters + ---------- + flow_item: FlowItem + The flow item to publish. This item must have a project_id and name + defined. + + file: PathOrFileR + The file path or file object to publish. This can be a .tfl or .tflx + + mode: str + The publish mode. This can be "Overwrite" or "CreatNew". If the + mode is "Overwrite", the flow will be overwritten if it already + exists. If the mode is "CreateNew", a new flow will be created with + the same name as the flow item. + + connections: Optional[list[ConnectionItem]] + A list of connection items to publish with the flow. If the flow + contains connections, they must be included in this list. + + Returns + ------- + FlowItem + The flow item that was published. + """ if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) @@ -265,30 +446,145 @@ def publish( @api(version="3.3") def populate_permissions(self, item: FlowItem) -> None: + """ + Populate the permissions for a flow item. This method will make a + request to the Tableau Server to get the permissions associated with + the flow item and populate the permissions property of the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_permissions + + Parameters + ---------- + item: FlowItem + The flow item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="3.3") def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: + """ + Update the permissions for a flow item. This method will update the + permissions for the flow item. The permissions must be a list of + permissions rules. Will overwrite all existing permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item: FlowItem + The flow item to update permissions for. + + permission_item: Iterable[PermissionsRule] + The permissions rules to update. + + Returns + ------- + None + """ self._permissions.update(item, permission_item) @api(version="3.3") def delete_permission(self, item: FlowItem, capability_item: "PermissionsRule") -> None: + """ + Delete a permission for a flow item. This method will delete only the + specified permission for the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow_permission + + Parameters + ---------- + item: FlowItem + The flow item to delete the permission from. + + capability_item: PermissionsRule + The permission to delete. + + Returns + ------- + None + """ self._permissions.delete(item, capability_item) @api(version="3.5") def populate_dqw(self, item: FlowItem) -> None: + """ + Get information about Data Quality Warnings for a flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws + + Parameters + ---------- + item: FlowItem + The flow item to populate data quality warnings for. + + Returns + ------- + None + """ self._data_quality_warnings.populate(item) @api(version="3.5") def update_dqw(self, item: FlowItem, warning: "DQWItem") -> None: + """ + Update the warning type, status, and message of a data quality warning + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw + + Parameters + ---------- + item: FlowItem + The flow item to update data quality warnings for. + + warning: DQWItem + The data quality warning to update. + + Returns + ------- + None + """ return self._data_quality_warnings.update(item, warning) @api(version="3.5") def add_dqw(self, item: FlowItem, warning: "DQWItem") -> None: + """ + Add a data quality warning to a flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw + + Parameters + ---------- + item: FlowItem + The flow item to add data quality warnings to. + + warning: DQWItem + The data quality warning to add. + + Returns + ------- + None + """ return self._data_quality_warnings.add(item, warning) @api(version="3.5") def delete_dqw(self, item: FlowItem) -> None: + """ + Delete all data quality warnings for a flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws + + Parameters + ---------- + item: FlowItem + The flow item to delete data quality warnings from. + + Returns + ------- + None + """ self._data_quality_warnings.clear(item) # a convenience method @@ -296,6 +592,24 @@ def delete_dqw(self, item: FlowItem) -> None: def schedule_flow_run( self, schedule_id: str, item: FlowItem ) -> list["AddResponse"]: # actually should return a task + """ + Schedule a flow to run on an existing schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id: str + The id of the schedule to add the flow to. + + item: FlowItem + The flow item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index c512b011b..4e9af4076 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union, overload from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -18,13 +18,56 @@ class Groups(QuerysetEndpoint[GroupItem]): + """ + Groups endpoint for creating, reading, updating, and deleting groups on + Tableau Server. + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: - """Gets all groups""" + """ + Returns information about the groups on the site. + + To get information about the users in a group, you must first populate + the GroupItem with user information using the groups.populate_users + method. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#query_groups + + Parameters + ---------- + req_options : Optional[RequestOptions] + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific group, you could specify the name of + the group or the group id. + + Returns + ------- + tuple[list[GroupItem], PaginationItem] + + Examples + -------- + >>> # import tableauserverclient as TSC + >>> # tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> # server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + + >>> # get the groups on the server + >>> all_groups, pagination_item = server.groups.get() + + >>> # print the names of the first 100 groups + >>> for group in all_groups : + >>> print(group.name, group.id) + + + + """ logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -34,7 +77,42 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Grou @api(version="2.0") def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None: - """Gets all users in a given group""" + """ + Populates the group_item with the list of users. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#get_users_in_group + + Parameters + ---------- + group_item : GroupItem + The group item to populate with user information. + + req_options : Optional[RequestOptions] + (Optional) You can pass the method a request object that contains + page size and page number. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the group item does not have an ID, the method raises an error. + + Examples + -------- + >>> # Get the group item from the server + >>> groups, pagination_item = server.groups.get() + >>> group = groups[1] + + >>> # Populate the group with user information + >>> server.groups.populate_users(group) + >>> for user in group.users: + >>> print(user.name) + + + """ if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -61,7 +139,32 @@ def _get_users_for_group( @api(version="2.0") def delete(self, group_id: str) -> None: - """Deletes 1 group by id""" + """ + Deletes the group on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#delete_group + + Parameters + ---------- + group_id: str + The id for the group you want to remove from the server + + Returns + ------- + None + + Raises + ------ + ValueError + If the group_id is not provided, the method raises an error. + + Examples + -------- + >>> groups, pagination_item = server.groups.get() + >>> group = groups[1] + >>> server.groups.delete(group.id) + + """ if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -69,8 +172,42 @@ def delete(self, group_id: str) -> None: self.delete_request(url) logger.info(f"Deleted single group (ID: {group_id})") + @overload + def update(self, group_item: GroupItem, as_job: Literal[False]) -> GroupItem: ... + + @overload + def update(self, group_item: GroupItem, as_job: Literal[True]) -> JobItem: ... + @api(version="2.0") - def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: + def update(self, group_item, as_job=False): + """ + Updates a group on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#update_group + + Parameters + ---------- + group_item : GroupItem + The group item to update. + + as_job : bool + (Optional) If this value is set to True, the update operation will + be asynchronous and return a JobItem. This is only supported for + Active Directory groups. By default, this value is set to False. + + Returns + ------- + Union[GroupItem, JobItem] + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the group_item is a local group and as_job is set to True, the + method raises an error. + """ url = f"{self.baseurl}/{group_item.id}" if not group_item.id: @@ -92,15 +229,71 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem @api(version="2.0") def create(self, group_item: GroupItem) -> GroupItem: - """Create a 'local' Tableau group""" + """ + Create a 'local' Tableau group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group + + Parameters + ---------- + group_item : GroupItem + The group item to create. The group_item specifies the group to add. + You first create a new instance of a GroupItem and pass that to this + method. + + Returns + ------- + GroupItem + + Examples + -------- + >>> new_group = TSC.GroupItem('new_group') + >>> new_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish + >>> new_group = server.groups.create(new_group) + + """ url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @overload + def create_AD_group(self, group_item: GroupItem, asJob: Literal[False]) -> GroupItem: ... + + @overload + def create_AD_group(self, group_item: GroupItem, asJob: Literal[True]) -> JobItem: ... + @api(version="2.0") - def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: - """Create a group based on Active Directory""" + def create_AD_group(self, group_item, asJob=False): + """ + Create a group based on Active Directory. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group + + Parameters + ---------- + group_item : GroupItem + The group item to create. The group_item specifies the group to add. + You first create a new instance of a GroupItem and pass that to this + method. + + asJob : bool + (Optional) If this value is set to True, the create operation will + be asynchronous and return a JobItem. This is only supported for + Active Directory groups. By default, this value is set to False. + + Returns + ------- + Union[GroupItem, JobItem] + + Examples + -------- + >>> new_ad_group = TSC.GroupItem('new_ad_group') + >>> new_ad_group.domain_name = 'example.com' + >>> new_ad_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish + >>> new_ad_group.license_mode = TSC.GroupItem.LicenseMode.onSync + >>> new_ad_group = server.groups.create_AD_group(new_ad_group) + """ asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -112,7 +305,37 @@ def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[G @api(version="2.0") def remove_user(self, group_item: GroupItem, user_id: str) -> None: - """Removes 1 user from 1 group""" + """ + Removes 1 user from 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item from which to remove the user. + + user_id : str + The ID of the user to remove from the group. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.populate_users(group) + >>> server.groups.remove_user(group, group.users[0].id) + """ if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -125,7 +348,37 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: - """Removes multiple users from 1 group""" + """ + Removes multiple users from 1 group. This makes a single API call to + remove the provided users. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_users_to_group + + Parameters + ---------- + group_item : GroupItem + The group item from which to remove the user. + + users : Iterable[Union[str, UserItem]] + The IDs or UserItems with IDs of the users to remove from the group. + + Returns + ------- + None + + Raises + ------ + ValueError + If the group_item is not a GroupItem or str, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.populate_users(group) + >>> users = [u for u in group.users if u.domain_name == 'example.com'] + >>> server.groups.remove_users(group, users) + + """ group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): raise ValueError(f"Invalid group provided: {group_item}") @@ -138,7 +391,37 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte @api(version="2.0") def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: - """Adds 1 user to 1 group""" + """ + Adds 1 user to 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item to which to add the user. + + user_id : str + The ID of the user to add to the group. + + Returns + ------- + UserItem + UserItem for the user that was added to the group. + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.add_user(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -154,6 +437,37 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: @api(version="3.21") def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: + """ + Adds 1 or more user to 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item to which to add the user. + + user_id : Iterable[Union[str, UserItem]] + User IDs or UserItems with IDs to add to the group. + + Returns + ------- + list[UserItem] + UserItem for the user that was added to the group. + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> added_users = server.groups.add_users(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 723d3dd38..027a7ca12 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -33,6 +33,32 @@ def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> @api(version="2.6") def get(self, job_id=None, req_options=None): + """ + Retrieve jobs for the site. Endpoint is paginated and will return a + list of jobs and pagination information. If a job_id is provided, the + method will return information about that specific job. Specifying a + job_id is deprecated and will be removed in a future version. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_jobs + + Parameters + ---------- + job_id : str or RequestOptionsBase + The ID of the job to retrieve. If None, the method will return all + jobs for the site. If a RequestOptions object is provided, the + method will use the options to filter the jobs. + + req_options : RequestOptionsBase + The request options to filter the jobs. If None, the method will + return all jobs for the site. + + Returns + ------- + tuple[list[BackgroundJobItem], PaginationItem] or JobItem + If a job_id is provided, the method will return a JobItem. If no + job_id is provided, the method will return a tuple containing a + list of BackgroundJobItems and a PaginationItem. + """ # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -50,6 +76,33 @@ def get(self, job_id=None, req_options=None): @api(version="3.1") def cancel(self, job_id: Union[str, JobItem]): + """ + Cancels a job specified by job ID. To get a list of job IDs for jobs that are currently queued or in-progress, use the Query Jobs method. + + The following jobs can be canceled using the Cancel Job method: + + Full extract refresh + Incremental extract refresh + Subscription + Flow Run + Data Acceleration (Data acceleration is not available in Tableau Server 2022.1 (API 3.16) and later. See View Acceleration(Link opens in a new window).) + Bridge full extract refresh + Bridge incremental extract refresh + Queue upgrade Thumbnail (Job that puts the upgrade thumbnail job on the queue) + Upgrade Thumbnail + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#cancel_job + + Parameters + ---------- + job_id : str or JobItem + The ID of the job to cancel. If a JobItem is provided, the method + will use the ID from the JobItem. + + Returns + ------- + None + """ if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) @@ -58,6 +111,32 @@ def cancel(self, job_id: Union[str, JobItem]): @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: + """ + Returns status information about an asynchronous process that is tracked + using a job. This method can be used to query jobs that are used to do + the following: + + Import users from Active Directory (the result of a call to Create Group). + Synchronize an existing Tableau Server group with Active Directory (the result of a call to Update Group). + Run extract refresh tasks (the result of a call to Run Extract Refresh Task). + Publish a workbook asynchronously (the result of a call to Publish Workbook). + Run workbook or view subscriptions (the result of a call to Create Subscription or Update Subscription) + Run a flow task (the result of a call to Run Flow Task) + Status of Tableau Server site deletion (the result of a call to asynchronous Delete Site(Link opens in a new window) beginning API 3.18) + Note: To query a site deletion job, the server administrator must be first signed into the default site (contentUrl=" "). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job + + Parameters + ---------- + job_id : str + The ID of the job to retrieve. + + Returns + ------- + JobItem + The JobItem object that contains information about the requested job. + """ logger.info("Query for information about job " + job_id) url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) @@ -65,6 +144,36 @@ def get_by_id(self, job_id: str) -> JobItem: return new_job def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] = None) -> JobItem: + """ + Waits for a job to complete. The method will poll the server for the job + status until the job is completed. If the job is successful, the method + will return the JobItem. If the job fails, the method will raise a + JobFailedException. If the job is cancelled, the method will raise a + JobCancelledException. + + Parameters + ---------- + job_id : str or JobItem + The ID of the job to wait for. If a JobItem is provided, the method + will use the ID from the JobItem. + + timeout : float | None + The maximum amount of time to wait for the job to complete. If None, + the method will wait indefinitely. + + Returns + ------- + JobItem + The JobItem object that contains information about the completed job. + + Raises + ------ + JobFailedException + If the job failed to complete. + + JobCancelledException + If the job was cancelled. + """ if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 74bb865c7..68eb573cc 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -20,6 +20,11 @@ class Projects(QuerysetEndpoint[ProjectItem]): + """ + The project methods are based upon the endpoints for projects in the REST + API and operate on the ProjectItem class. + """ + def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) @@ -32,6 +37,23 @@ def baseurl(self) -> str: @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: + """ + Retrieves all projects on the site. The endpoint is paginated and can + be filtered using the req_options parameter. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#query_projects + + Parameters + ---------- + req_options : RequestOptions | None, default None + The request options to filter the projects. The default is None. + + Returns + ------- + tuple[list[ProjectItem], PaginationItem] + Returns a tuple containing a list of ProjectItem objects and a + PaginationItem object. + """ logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -41,6 +63,25 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Proj @api(version="2.0") def delete(self, project_id: str) -> None: + """ + Deletes a single project on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#delete_project + + Parameters + ---------- + project_id : str + The unique identifier for the project. + + Returns + ------- + None + + Raises + ------ + ValueError + If the project ID is not defined, an error is raised. + """ if not project_id: error = "Project ID undefined." raise ValueError(error) @@ -50,6 +91,36 @@ def delete(self, project_id: str) -> None: @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + """ + Modify the project settings. + + You can use this method to update the project name, the project + description, or the project permissions. To specify the site, create a + TableauAuth instance using the content URL for the site (site_id), and + sign in to that site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#update_project + + Parameters + ---------- + project_item : ProjectItem + The project item object must include the project ID. The values in + the project item override the current project settings. + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Returns + ------- + ProjectItem + Returns the updated project item. + + Raises + ------ + MissingRequiredFieldError + If the project item is missing the ID, an error is raised. + """ if not project_item.id: error = "Project item missing ID." raise MissingRequiredFieldError(error) @@ -64,6 +135,32 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte @api(version="2.0") def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + """ + Creates a project on the specified site. + + To create a project, you first create a new instance of a ProjectItem + and pass it to the create method. To specify the site to create the new + project, create a TableauAuth instance using the content URL for the + site (site_id), and sign in to that site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#create_project + + Parameters + ---------- + project_item : ProjectItem + Specifies the properties for the project. The project_item is the + request package. To create the request package, create a new + instance of ProjectItem. + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Returns + ------- + ProjectItem + Returns the new project item. + """ params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: @@ -76,136 +173,639 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte @api(version="2.0") def populate_permissions(self, item: ProjectItem) -> None: + """ + Queries the project permissions, parses and stores the returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_project_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with permissions. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified project item. The rules + provided are expected to be a complete list of the permissions for the + project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ + return self._permissions.update(item, rules) @api(version="2.0") def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: + """ + Deletes the specified permissions from the project item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_project_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete permissions from. + + rules : list[PermissionsRule] + The list of permissions rules to delete from the project. + + Returns + ------- + None + """ self._permissions.delete(item, rules) @api(version="2.1") def populate_workbook_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default workbook permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default workbook permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") def populate_datasource_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default datasource permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default datasource permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") def populate_metric_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default metric permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default metric permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") def populate_datarole_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default datarole permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default datarole permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") def populate_flow_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default flow permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default flow permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") def populate_lens_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default lens permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default lens permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Lens) @api(version="3.23") def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default virtualconnections permissions, parses and stores + the returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default virtual connection + permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) @api(version="3.23") def populate_database_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default database permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default database permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Database) @api(version="3.23") def populate_table_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default table permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default table permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="2.1") def update_workbook_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default workbook permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default workbook permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") def update_datasource_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default datasource permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default datasource permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") def update_metric_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default metric permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default metric permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") def update_datarole_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default datarole permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default datarole permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Add or updates the default flow permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default flow permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Add or updates the default lens permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default lens permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) @api(version="3.23") def update_virtualconnection_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default virtualconnection permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default virtualconnection permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) @api(version="3.23") def update_database_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default database permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default database permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Database) @api(version="3.23") def update_table_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default table permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default table permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="2.1") def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default workbook permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default datasource permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default workbook permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default datarole permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default flow permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default lens permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Lens) @api(version="3.23") def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default virtualconnection permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) @api(version="3.23") def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default database permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Database) @api(version="3.23") def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default table permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Table) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index eb82c43bc..e1e95041d 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -31,6 +31,24 @@ def __normalize_task_type(self, task_type: str) -> str: def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh ) -> tuple[list[TaskItem], PaginationItem]: + """ + Returns information about tasks on the specified site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#list_extract_refresh_tasks + + Parameters + ---------- + req_options : RequestOptions, optional + Options for the request, such as filtering, sorting, and pagination. + + task_type : str, optional + The type of task to query. See TaskItem.Type for possible values. + + Returns + ------- + tuple[list[TaskItem], PaginationItem] + + """ if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") @@ -45,6 +63,20 @@ def get( @api(version="2.6") def get_by_id(self, task_id: str) -> TaskItem: + """ + Returns information about the specified task. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get_extract_refresh_task + + Parameters + ---------- + task_id : str + The ID of the task to query. + + Returns + ------- + TaskItem + """ if not task_id: error = "No Task ID provided" raise ValueError(error) @@ -59,6 +91,21 @@ def get_by_id(self, task_id: str) -> TaskItem: @api(version="3.19") def create(self, extract_item: TaskItem) -> TaskItem: + """ + Creates a custom schedule for an extract refresh on Tableau Cloud. For + Tableau Server, use the Schedules endpoint to create a schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_cloud_extract_refresh_task + + Parameters + ---------- + extract_item : TaskItem + The extract refresh task to create. + + Returns + ------- + TaskItem + """ if not extract_item: error = "No extract refresh provided" raise ValueError(error) @@ -70,6 +117,20 @@ def create(self, extract_item: TaskItem) -> TaskItem: @api(version="2.6") def run(self, task_item: TaskItem) -> bytes: + """ + Runs the specified extract refresh task. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task + + Parameters + ---------- + task_item : TaskItem + The task to run. + + Returns + ------- + bytes + """ if not task_item.id: error = "Task item missing ID." raise MissingRequiredFieldError(error) @@ -86,6 +147,23 @@ def run(self, task_item: TaskItem) -> bytes: # Delete 1 task by id @api(version="3.6") def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> None: + """ + Deletes the specified extract refresh task on Tableau Server or Tableau Cloud. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extract_refresh_task + + Parameters + ---------- + task_id : str + The ID of the task to delete. + + task_type : str, default TaskItem.Type.ExtractRefresh + The type of task to query. See TaskItem.Type for possible values. + + Returns + ------- + None + """ if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 3709fc41d..12b386876 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,6 +1,7 @@ import logging from contextlib import closing +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint @@ -25,6 +26,12 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): + """ + The Tableau Server Client provides methods for interacting with view + resources, or endpoints. These methods correspond to the endpoints for views + in the Tableau Server REST API. + """ + def __init__(self, parent_srv): super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -42,6 +49,24 @@ def baseurl(self) -> str: def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False ) -> tuple[list[ViewItem], PaginationItem]: + """ + Returns the list of views on the site. Paginated endpoint. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_views_for_site + + Parameters + ---------- + req_options: Optional[RequestOptions], default None + The request options for the request. These options can include + parameters such as page size and sorting. + + usage: bool, default False + If True, includes usage statistics in the response. + + Returns + ------- + views: tuple[list[ViewItem], PaginationItem] + """ logger.info("Querying all views on site") url = self.baseurl if usage: @@ -53,6 +78,23 @@ def get( @api(version="3.1") def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: + """ + Returns the details of a specific view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_view + + Parameters + ---------- + view_id: str + The view ID. + + usage: bool, default False + If True, includes usage statistics in the response. + + Returns + ------- + view_item: ViewItem + """ if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -65,6 +107,24 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: @api(version="2.0") def populate_preview_image(self, view_item: ViewItem) -> None: + """ + Populates a preview image for the specified view. + + This method gets the preview image (thumbnail) for the specified view + item. The method uses the id and workbook_id fields to query the preview + image. The method populates the preview_image for the view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_with_preview + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the preview image. + + Returns + ------- + None + """ if not view_item.id or not view_item.workbook_id: error = "View item missing ID or workbook ID." raise MissingRequiredFieldError(error) @@ -83,6 +143,27 @@ def _get_preview_for_view(self, view_item: ViewItem) -> bytes: @api(version="2.5") def populate_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + """ + Populates the image of the specified view. + + This method uses the id field to query the image, and populates the + image content as the image field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_image + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the image. + + req_options: Optional[ImageRequestOptions], default None + Optional request options for the request. These options can include + parameters such as image resolution and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -101,6 +182,26 @@ def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageReque @api(version="2.7") def populate_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + """ + Populates the PDF content of the specified view. + + This method populates a PDF with image(s) of the view you specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_pdf + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the PDF. + + req_options: Optional[PDFRequestOptions], default None + Optional request options for the request. These options can include + parameters such as orientation and paper size. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -119,6 +220,27 @@ def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOp @api(version="2.7") def populate_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + """ + Populates the CSV data of the specified view. + + This method uses the id field to query the CSV data, and populates the + data as the csv field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_data + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the CSV data. + + req_options: Optional[CSVRequestOptions], default None + Optional request options for the request. These options can include + parameters such as view filters and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -137,6 +259,27 @@ def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOp @api(version="3.8") def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"] = None) -> None: + """ + Populates the Excel data of the specified view. + + This method uses the id field to query the Excel data, and populates the + data as the Excel field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_view_excel + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the Excel data. + + req_options: Optional[ExcelRequestOptions], default None + Optional request options for the request. These options can include + parameters such as view filters and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -155,18 +298,66 @@ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelReque @api(version="3.2") def populate_permissions(self, item: ViewItem) -> None: + """ + Returns a list of permissions for the specific view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_view_permissions + + Parameters + ---------- + item: ViewItem + The view item for which to populate the permissions. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="3.2") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: ViewItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ """ return self._permissions.update(resource, rules) @api(version="3.2") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: ViewItem, capability_item: PermissionsRule) -> None: + """ + Deletes permission to the specified view (also known as a sheet) for a + Tableau Server user or group. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_view_permission + + Parameters + ---------- + item: ViewItem + The view item for which to delete the permission. + + capability_item: PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) # Update view. Currently only tags can be updated def update(self, view_item: ViewItem) -> ViewItem: + """ + Updates the tags for the specified view. All other fields are managed + through the WorkbookItem object. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + view_item: ViewItem + The view item for which to update tags. + + Returns + ------- + ViewItem + """ if not view_item.id: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -178,14 +369,64 @@ def update(self, view_item: ViewItem) -> ViewItem: @api(version="1.0") def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to the specified view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + item: Union[ViewItem, str] + The view item or view ID to which to add tags. + + tags: Union[Iterable[str], str] + The tags to add to the view. + + Returns + ------- + set[str] + + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from the specified view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tags_from_view + + Parameters + ---------- + item: Union[ViewItem, str] + The view item or view ID from which to delete tags. + + tags: Union[Iterable[str], str] + The tags to delete from the view. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: ViewItem) -> None: + """ + Updates the tags for the specified view. Any changes to the tags must + be made by editing the tags attribute of the view item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + item: ViewItem + The view item for which to update tags. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 06643f99d..e5c7b5897 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -23,6 +23,21 @@ def baseurl(self) -> str: @api(version="3.6") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: + """ + Returns a list of all webhooks on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#list_webhooks_for_site + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filter and sorting options for the request. + + Returns + ------- + tuple[list[WebhookItem], PaginationItem] + A tuple of the list of webhooks and pagination item + """ logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -32,6 +47,21 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Webh @api(version="3.6") def get_by_id(self, webhook_id: str) -> WebhookItem: + """ + Returns information about a specified Webhook. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#get_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to query. + + Returns + ------- + WebhookItem + An object containing information about the webhook. + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -42,6 +72,20 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: @api(version="3.6") def delete(self, webhook_id: str) -> None: + """ + Deletes a specified webhook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to delete. + + Returns + ------- + None + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -51,6 +95,21 @@ def delete(self, webhook_id: str) -> None: @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: + """ + Creates a new webhook on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_webhook + + Parameters + ---------- + webhook_item : WebhookItem + The webhook item to create. + + Returns + ------- + WebhookItem + An object containing information about the created webhook + """ url = self.baseurl create_req = RequestFactory.Webhook.create_req(webhook_item) server_response = self.post_request(url, create_req) @@ -61,6 +120,24 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: @api(version="3.6") def test(self, webhook_id: str): + """ + Tests the specified webhook. Sends an empty payload to the configured + destination URL of the webhook and returns the response from the server. + This is useful for testing, to ensure that things are being sent from + Tableau and received back as expected. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#test_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to test. + + Returns + ------- + XML Response + + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 460017d1a..4fdcf075b 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -118,7 +118,7 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem: """ Refreshes the extract of an existing workbook. @@ -126,6 +126,8 @@ def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: ---------- workbook_item : WorkbookItem | str The workbook item or workbook ID. + incremental: bool + Whether to do a full refresh or incremental refresh of the extract data Returns ------- @@ -134,8 +136,8 @@ def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + refresh_req = RequestFactory.Task.refresh_req(incremental) + server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -280,7 +282,7 @@ def update( if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" - update_req = RequestFactory.Workbook.update_req(workbook_item) + update_req = RequestFactory.Workbook.update_req(workbook_item, self.parent_srv) server_response = self.put_request(url, update_req) logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index e6d261b61..3c7e60f74 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -27,6 +27,30 @@ class Pager(Iterable[T]): (users in a group, views in a workbook, etc) by passing a different endpoint. Will loop over anything that returns (list[ModelItem], PaginationItem). + + Will make a copy of the `RequestOptions` object passed in so it can be reused. + + Makes a call to the Server for each page of items, then yields each item in the list. + + Parameters + ---------- + endpoint: CallableEndpoint[T] or Endpoint[T] + The endpoint to call to get the items. Can be a callable or an Endpoint object. + Expects a tuple of (list[T], PaginationItem) to be returned. + + request_opts: RequestOptions, optional + The request options to pass to the endpoint. If not provided, will use default RequestOptions. + Filters, sorts, page size, starting page number, etc can be set here. + + Yields + ------ + T + The items returned from the endpoint. + + Raises + ------ + ValueError + If the endpoint is not a callable or an Endpoint object. """ def __init__( diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f7bd139d7..79ac6e4ca 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -958,9 +958,15 @@ def _generate_xml( views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: _add_hiddenview_element(views_element, view_name) + + if workbook_item.thumbnails_user_id is not None: + workbook_element.attrib["thumbnailsUserId"] = workbook_item.thumbnails_user_id + elif workbook_item.thumbnails_group_id is not None: + workbook_element.attrib["thumbnailsGroupId"] = workbook_item.thumbnails_group_id + return ET.tostring(xml_request) - def update_req(self, workbook_item): + def update_req(self, workbook_item, parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") if workbook_item.name: @@ -973,6 +979,12 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, "owner") owner_element.attrib["id"] = workbook_item.owner_id + if ( + workbook_item.description is not None + and parent_srv is not None + and parent_srv.check_at_least_version("3.21") + ): + workbook_element.attrib["description"] = workbook_item.description if workbook_item._views is not None: views_element = ET.SubElement(workbook_element, "views") for view in workbook_item.views: @@ -1105,6 +1117,13 @@ def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest pass + @_tsrequest_wrapped + def refresh_req(self, xml_request: ET.Element, incremental: bool = False) -> bytes: + task_element = ET.SubElement(xml_request, "extractRefresh") + if incremental: + task_element.attrib["incremental"] = "true" + return ET.tostring(xml_request) + @_tsrequest_wrapped def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: extract_element = ET.SubElement(xml_request, "extractRefresh") diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index d79ac7f73..c37c0ce42 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -35,6 +35,28 @@ def apply_query_params(self, url): class RequestOptions(RequestOptionsBase): + """ + This class is used to manage the options that can be used when querying content on the server. + Optionally initialize with a page number and page size to control the number of items returned. + + Additionally, you can add sorting and filtering options to the request. + + The `sort` and `filter` options are set-like objects, so you can only add a field once. If you add the same field + multiple times, only the last one will be used. + + The list of fields that can be sorted on or filtered by can be found in the `Field` + class contained within this class. + + Parameters + ---------- + pagenumber: int, optional + The page number to start the query on. Default is 1. + + pagesize: int, optional + The number of items to return per page. Default is 100. Can also read + from the environment variable `TSC_PAGE_SIZE` + """ + def __init__(self, pagenumber=1, pagesize=None): self.pagenumber = pagenumber self.pagesize = pagesize or config.PAGE_SIZE @@ -122,6 +144,7 @@ class Field: NotificationType = "notificationType" OwnerDomain = "ownerDomain" OwnerEmail = "ownerEmail" + OwnerId = "ownerId" OwnerName = "ownerName" ParentProjectId = "parentProjectId" Priority = "priority" @@ -148,8 +171,10 @@ class Field: UpdatedAt = "updatedAt" UserCount = "userCount" UserId = "userId" + ViewId = "viewId" ViewUrlName = "viewUrlName" WorkbookDescription = "workbookDescription" + WorkbookId = "workbookId" WorkbookName = "workbookName" class Direction: @@ -196,13 +221,43 @@ def get_query_params(self): def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false' + + For more detail see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views + + Parameters + ---------- + name: str + The name of the column to filter on + + value: str + The value to filter on + + Returns + ------- + Self + The current object + """ self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: """Apply a filter based on a parameter within the workbook. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false' + + Parameters + ---------- + name: str + The name of the parameter to filter on + + value: str + The value to filter on + + Returns + ------- + Self + The current object + """ self.view_parameters.append((name, value)) return self @@ -254,14 +309,60 @@ def get_query_params(self) -> dict: class CSVRequestOptions(_DataExportOptions): + """ + Options that can be used when exporting a view to CSV. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + extension = "csv" class ExcelRequestOptions(_DataExportOptions): + """ + Options that can be used when exporting a view to Excel. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + extension = "xlsx" class ImageRequestOptions(_ImagePDFCommonExportOptions): + """ + Options that can be used when exporting a view to an image. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + imageresolution: str, optional + The resolution of the image to export. Valid values are "high" or None. Default is None. + Image width and actual pixel density are determined by the display context + of the image. Aspect ratio is always preserved. Set the value to "high" to + ensure maximum pixel density. + + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + + viz_height: int, optional + The height of the viz in pixels. If specified, viz_width must also be specified. + + viz_width: int, optional + The width of the viz in pixels. If specified, viz_height must also be specified. + + """ + extension = "png" # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution @@ -280,6 +381,29 @@ def get_query_params(self): class PDFRequestOptions(_ImagePDFCommonExportOptions): + """ + Options that can be used when exporting a view to PDF. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + page_type: str, optional + The page type of the PDF to export. Valid values are accessible via the `PageType` class. + + orientation: str, optional + The orientation of the PDF to export. Valid values are accessible via the `Orientation` class. + + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + + viz_height: int, optional + The height of the viz in pixels. If specified, viz_width must also be specified. + + viz_width: int, optional + The width of the viz in pixels. If specified, viz_height must also be specified. + """ + class PageType: A3 = "a3" A4 = "a4" diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 4eeefcaf9..30c635e31 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -119,6 +119,7 @@ class PublishMode: Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" + Replace = "Replace" def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None): self._auth_token = None @@ -207,12 +208,14 @@ def _clear_auth(self): self._site_id = None self._user_id = None self._auth_token = None + self._site_url = None self._session = self._session_factory() - def _set_auth(self, site_id, user_id, auth_token): + def _set_auth(self, site_id, user_id, auth_token, site_url=None): self._site_id = site_id self._user_id = user_id self._auth_token = auth_token + self._site_url = site_url def _get_legacy_version(self): # the serverInfo call was introduced in 2.4, earlier than that we have this different call @@ -282,6 +285,13 @@ def site_id(self): raise NotSignedInError(error) return self._site_id + @property + def site_url(self): + if self._site_url is None: + error = "Missing site URL. You must sign in first." + raise NotSignedInError(error) + return self._site_url + @property def user_id(self): if self._user_id is None: diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 839a8c8db..b78645921 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,4 +1,18 @@ class Sort: + """ + Used with request options (RequestOptions) where you can filter and sort on + the results returned from the server. + + Parameters + ---------- + field : str + Sets the field to sort on. The fields are defined in the RequestOption class. + + direction : str + The direction to sort, either ascending (Asc) or descending (Desc). The + options are defined in the RequestOptions.Direction class. + """ + def __init__(self, field, direction): self.field = field self.direction = direction diff --git a/test/assets/project_populate_virtualconnection_default_permissions.xml b/test/assets/project_populate_virtualconnection_default_permissions.xml new file mode 100644 index 000000000..10678f794 --- /dev/null +++ b/test/assets/project_populate_virtualconnection_default_permissions.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_update_virtualconnection_default_permissions.xml b/test/assets/project_update_virtualconnection_default_permissions.xml new file mode 100644 index 000000000..10b5ba6ec --- /dev/null +++ b/test/assets/project_update_virtualconnection_default_permissions.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index 48100ad88..09e3e251d 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -27,6 +27,7 @@ def test_sign_in(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_with_personal_access_tokens(self): @@ -41,6 +42,7 @@ def test_sign_in_with_personal_access_tokens(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_impersonate(self): @@ -93,6 +95,7 @@ def test_sign_out(self): self.assertIsNone(self.server._auth_token) self.assertIsNone(self.server._site_id) + self.assertIsNone(self.server._site_url) self.assertIsNone(self.server._user_id) def test_switch_site(self): @@ -109,6 +112,7 @@ def test_switch_site(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_revoke_all_server_admin_tokens(self): @@ -125,4 +129,5 @@ def test_revoke_all_server_admin_tokens(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 45d9ba9c9..e8a95722b 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -174,17 +174,22 @@ def test_populate_connections(self) -> None: connections: Optional[list[ConnectionItem]] = single_datasource.connections self.assertIsNotNone(connections) + assert connections is not None ds1, ds2 = connections self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) self.assertEqual("textscan", ds1.connection_type) self.assertEqual("forty-two.net", ds1.server_address) self.assertEqual("duo", ds1.username) self.assertEqual(True, ds1.embed_password) + self.assertEqual(ds1.datasource_id, single_datasource.id) + self.assertEqual(single_datasource.name, ds1.datasource_name) self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id) self.assertEqual("sqlserver", ds2.connection_type) self.assertEqual("database.com", ds2.server_address) self.assertEqual("heero", ds2.username) self.assertEqual(False, ds2.embed_password) + self.assertEqual(ds2.datasource_id, single_datasource.id) + self.assertEqual(single_datasource.name, ds2.datasource_name) def test_update_connection(self) -> None: populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) diff --git a/test/test_project.py b/test/test_project.py index 430db84b2..56787efac 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -16,6 +16,8 @@ POPULATE_PERMISSIONS_XML = "project_populate_permissions.xml" POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = "project_populate_workbook_default_permissions.xml" UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = "project_update_datasource_default_permissions.xml" +POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_populate_virtualconnection_default_permissions.xml" +UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_update_virtualconnection_default_permissions.xml" class ProjectTests(unittest.TestCase): @@ -303,3 +305,108 @@ def test_delete_workbook_default_permission(self) -> None: m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) + + def test_populate_virtualconnection_default_permissions(self): + response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + self.server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions + + rule = permissions.pop() + + self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule.grantee.id) + self.assertEqual("group", rule.grantee.tag_name) + self.assertDictEqual( + rule.capabilities, + { + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + }, + ) + + def test_update_virtualconnection_default_permissions(self): + response_xml = read_xml_asset(UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.put( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + capabilities = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + } + + rules = [TSC.PermissionsRule(GroupItem.as_reference(group.id), capabilities)] + new_rules = self.server.projects.update_virtualconnection_default_permissions(project, rules) + + rule = new_rules.pop() + + self.assertEqual(group.id, rule.grantee.id) + self.assertEqual("group", rule.grantee.tag_name) + self.assertDictEqual( + rule.capabilities, + { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + }, + ) + + def test_delete_virtualconnection_default_permimssions(self): + response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + self.server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions + + del_caps = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + } + + rule = TSC.PermissionsRule(GroupItem.as_reference(group.id), del_caps) + + endpoint = f"{project.id}/default-permissions/virtualConnections/groups/{group.id}" + m.delete(f"{base_url}/{endpoint}/ChangeHierarchy/Deny", status_code=204) + m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) + + self.server.projects.delete_virtualconnection_default_permissions(project, rule) diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a6b3192f..0aa52f50d 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -624,6 +624,45 @@ def test_publish_with_hidden_views_on_workbook(self) -> None: self.assertTrue(re.search(rb"<\/views>", request_body)) self.assertTrue(re.search(rb"<\/views>", request_body)) + def test_publish_with_thumbnails_user_id(self) -> None: + with open(PUBLISH_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + + new_workbook = TSC.WorkbookItem( + name="Sample", + show_tabs=False, + project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", + thumbnails_user_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20761", + ) + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = self.server.PublishMode.CreateNew + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + # order of attributes in xml is unspecified + self.assertTrue(re.search(rb"thumbnailsUserId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\"", request_body)) + + def test_publish_with_thumbnails_group_id(self) -> None: + with open(PUBLISH_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + + new_workbook = TSC.WorkbookItem( + name="Sample", + show_tabs=False, + project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", + thumbnails_group_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20762", + ) + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = self.server.PublishMode.CreateNew + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + self.assertTrue(re.search(rb"thumbnailsGroupId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20762\"", request_body)) + @pytest.mark.filterwarnings("ignore:'as_job' not available") def test_publish_with_query_params(self) -> None: with open(PUBLISH_ASYNC_XML, "rb") as f: diff --git a/versioneer.py b/versioneer.py deleted file mode 100644 index cce899f58..000000000 --- a/versioneer.py +++ /dev/null @@ -1,1845 +0,0 @@ -#!/usr/bin/env python -# Version: 0.18 - -"""The Versioneer - like a rocketeer, but for versions. - -The Versioneer -============== - -* like a rocketeer, but for versions! -* https://github.com/warner/python-versioneer -* Brian Warner -* License: Public Domain -* Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy -* [![Latest Version] -(https://pypip.in/version/versioneer/badge.svg?style=flat) -](https://pypi.python.org/pypi/versioneer/) -* [![Build Status] -(https://travis-ci.org/warner/python-versioneer.png?branch=master) -](https://travis-ci.org/warner/python-versioneer) - -This is a tool for managing a recorded version number in distutils-based -python projects. The goal is to remove the tedious and error-prone "update -the embedded version string" step from your release process. Making a new -release should be as easy as recording a new tag in your version-control -system, and maybe making new tarballs. - - -## Quick Install - -* `pip install versioneer` to somewhere to your $PATH -* add a `[versioneer]` section to your setup.cfg (see below) -* run `versioneer install` in your source tree, commit the results - -## Version Identifiers - -Source trees come from a variety of places: - -* a version-control system checkout (mostly used by developers) -* a nightly tarball, produced by build automation -* a snapshot tarball, produced by a web-based VCS browser, like github's - "tarball from tag" feature -* a release tarball, produced by "setup.py sdist", distributed through PyPI - -Within each source tree, the version identifier (either a string or a number, -this tool is format-agnostic) can come from a variety of places: - -* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows - about recent "tags" and an absolute revision-id -* the name of the directory into which the tarball was unpacked -* an expanded VCS keyword ($Id$, etc) -* a `_version.py` created by some earlier build step - -For released software, the version identifier is closely related to a VCS -tag. Some projects use tag names that include more than just the version -string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool -needs to strip the tag prefix to extract the version identifier. For -unreleased software (between tags), the version identifier should provide -enough information to help developers recreate the same tree, while also -giving them an idea of roughly how old the tree is (after version 1.2, before -version 1.3). Many VCS systems can report a description that captures this, -for example `git describe --tags --dirty --always` reports things like -"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the -0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes. - -The version identifier is used for multiple purposes: - -* to allow the module to self-identify its version: `myproject.__version__` -* to choose a name and prefix for a 'setup.py sdist' tarball - -## Theory of Operation - -Versioneer works by adding a special `_version.py` file into your source -tree, where your `__init__.py` can import it. This `_version.py` knows how to -dynamically ask the VCS tool for version information at import time. - -`_version.py` also contains `$Revision$` markers, and the installation -process marks `_version.py` to have this marker rewritten with a tag name -during the `git archive` command. As a result, generated tarballs will -contain enough information to get the proper version. - -To allow `setup.py` to compute a version too, a `versioneer.py` is added to -the top level of your source tree, next to `setup.py` and the `setup.cfg` -that configures it. This overrides several distutils/setuptools commands to -compute the version when invoked, and changes `setup.py build` and `setup.py -sdist` to replace `_version.py` with a small static file that contains just -the generated version data. - -## Installation - -See [INSTALL.md](./INSTALL.md) for detailed installation instructions. - -## Version-String Flavors - -Code which uses Versioneer can learn about its version string at runtime by -importing `_version` from your main `__init__.py` file and running the -`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can -import the top-level `versioneer.py` and run `get_versions()`. - -Both functions return a dictionary with different flavors of version -information: - -* `['version']`: A condensed version string, rendered using the selected - style. This is the most commonly used value for the project's version - string. The default "pep440" style yields strings like `0.11`, - `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section - below for alternative styles. - -* `['full-revisionid']`: detailed revision identifier. For Git, this is the - full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". - -* `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the - commit date in ISO 8601 format. This will be None if the date is not - available. - -* `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that - this is only accurate if run in a VCS checkout, otherwise it is likely to - be False or None - -* `['error']`: if the version string could not be computed, this will be set - to a string describing the problem, otherwise it will be None. It may be - useful to throw an exception in setup.py if this is set, to avoid e.g. - creating tarballs with a version string of "unknown". - -Some variants are more useful than others. Including `full-revisionid` in a -bug report should allow developers to reconstruct the exact code being tested -(or indicate the presence of local changes that should be shared with the -developers). `version` is suitable for display in an "about" box or a CLI -`--version` output: it can be easily compared against release notes and lists -of bugs fixed in various releases. - -The installer adds the following text to your `__init__.py` to place a basic -version in `YOURPROJECT.__version__`: - - from ._version import get_versions - __version__ = get_versions()['version'] - del get_versions - -## Styles - -The setup.cfg `style=` configuration controls how the VCS information is -rendered into a version string. - -The default style, "pep440", produces a PEP440-compliant string, equal to the -un-prefixed tag name for actual releases, and containing an additional "local -version" section with more detail for in-between builds. For Git, this is -TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags ---dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the -tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and -that this commit is two revisions ("+2") beyond the "0.11" tag. For released -software (exactly equal to a known tag), the identifier will only contain the -stripped tag, e.g. "0.11". - -Other styles are available. See [details.md](details.md) in the Versioneer -source tree for descriptions. - -## Debugging - -Versioneer tries to avoid fatal errors: if something goes wrong, it will tend -to return a version of "0+unknown". To investigate the problem, run `setup.py -version`, which will run the version-lookup code in a verbose mode, and will -display the full contents of `get_versions()` (including the `error` string, -which may help identify what went wrong). - -## Known Limitations - -Some situations are known to cause problems for Versioneer. This details the -most significant ones. More can be found on Github -[issues page](https://github.com/warner/python-versioneer/issues). - -### Subprojects - -Versioneer has limited support for source trees in which `setup.py` is not in -the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are -two common reasons why `setup.py` might not be in the root: - -* Source trees which contain multiple subprojects, such as - [Buildbot](https://github.com/buildbot/buildbot), which contains both - "master" and "slave" subprojects, each with their own `setup.py`, - `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI - distributions (and upload multiple independently-installable tarballs). -* Source trees whose main purpose is to contain a C library, but which also - provide bindings to Python (and perhaps other langauges) in subdirectories. - -Versioneer will look for `.git` in parent directories, and most operations -should get the right version string. However `pip` and `setuptools` have bugs -and implementation details which frequently cause `pip install .` from a -subproject directory to fail to find a correct version string (so it usually -defaults to `0+unknown`). - -`pip install --editable .` should work correctly. `setup.py install` might -work too. - -Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in -some later version. - -[Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking -this issue. The discussion in -[PR #61](https://github.com/warner/python-versioneer/pull/61) describes the -issue from the Versioneer side in more detail. -[pip PR#3176](https://github.com/pypa/pip/pull/3176) and -[pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve -pip to let Versioneer work correctly. - -Versioneer-0.16 and earlier only looked for a `.git` directory next to the -`setup.cfg`, so subprojects were completely unsupported with those releases. - -### Editable installs with setuptools <= 18.5 - -`setup.py develop` and `pip install --editable .` allow you to install a -project into a virtualenv once, then continue editing the source code (and -test) without re-installing after every change. - -"Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a -convenient way to specify executable scripts that should be installed along -with the python package. - -These both work as expected when using modern setuptools. When using -setuptools-18.5 or earlier, however, certain operations will cause -`pkg_resources.DistributionNotFound` errors when running the entrypoint -script, which must be resolved by re-installing the package. This happens -when the install happens with one version, then the egg_info data is -regenerated while a different version is checked out. Many setup.py commands -cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into -a different virtualenv), so this can be surprising. - -[Bug #83](https://github.com/warner/python-versioneer/issues/83) describes -this one, but upgrading to a newer version of setuptools should probably -resolve it. - -### Unicode version strings - -While Versioneer works (and is continually tested) with both Python 2 and -Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. -Newer releases probably generate unicode version strings on py2. It's not -clear that this is wrong, but it may be surprising for applications when then -write these strings to a network connection or include them in bytes-oriented -APIs like cryptographic checksums. - -[Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates -this question. - - -## Updating Versioneer - -To upgrade your project to a new release of Versioneer, do the following: - -* install the new Versioneer (`pip install -U versioneer` or equivalent) -* edit `setup.cfg`, if necessary, to include any new configuration settings - indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. -* re-run `versioneer install` in your source tree, to replace - `SRC/_version.py` -* commit any changed files - -## Future Directions - -This tool is designed to make it easily extended to other version-control -systems: all VCS-specific components are in separate directories like -src/git/ . The top-level `versioneer.py` script is assembled from these -components by running make-versioneer.py . In the future, make-versioneer.py -will take a VCS name as an argument, and will construct a version of -`versioneer.py` that is specific to the given VCS. It might also take the -configuration arguments that are currently provided manually during -installation by editing setup.py . Alternatively, it might go the other -direction and include code from all supported VCS systems, reducing the -number of intermediate scripts. - - -## License - -To make Versioneer easier to embed, all its code is dedicated to the public -domain. The `_version.py` that it creates is also in the public domain. -Specifically, both are released under the Creative Commons "Public Domain -Dedication" license (CC0-1.0), as described in -https://creativecommons.org/publicdomain/zero/1.0/ . - -""" - - -try: - import configparser -except ImportError: - import ConfigParser as configparser -import errno -import json -import os -import re -import subprocess -import sys - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - -def get_root(): - """Get the project root directory. - - We require that all commands are run from the project root, i.e. the - directory that contains setup.py, setup.cfg, and versioneer.py . - """ - root = os.path.realpath(os.path.abspath(os.getcwd())) - setup_py = os.path.join(root, "setup.py") - versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - # allow 'python path/to/setup.py COMMAND' - root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) - setup_py = os.path.join(root, "setup.py") - versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - err = ( - "Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND')." - ) - raise VersioneerBadRootError(err) - try: - # Certain runtime workflows (setup.py install/develop in a setuptools - # tree) execute all dependencies in a single python process, so - # "versioneer" may be imported multiple times, and python's shared - # module-import table will cache the first one. So we can't use - # os.path.dirname(__file__), as that will find whichever - # versioneer.py was first imported, even in later projects. - me = os.path.realpath(os.path.abspath(__file__)) - me_dir = os.path.normcase(os.path.splitext(me)[0]) - vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) - if me_dir != vsr_dir: - print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") - except NameError: - pass - return root - - -def get_config_from_root(root): - """Read the project setup.cfg file to determine Versioneer config.""" - # This might raise EnvironmentError (if setup.cfg is missing), or - # configparser.NoSectionError (if it lacks a [versioneer] section), or - # configparser.NoOptionError (if it lacks "VCS="). See the docstring at - # the top of versioneer.py for instructions on writing your setup.cfg . - setup_cfg = os.path.join(root, "setup.cfg") - parser = configparser.SafeConfigParser() - with open(setup_cfg) as f: - parser.readfp(f) - VCS = parser.get("versioneer", "VCS") # mandatory - - def get(parser, name): - if parser.has_option("versioneer", name): - return parser.get("versioneer", name) - return None - - cfg = VersioneerConfig() - cfg.VCS = VCS - cfg.style = get(parser, "style") or "" - cfg.versionfile_source = get(parser, "versionfile_source") - cfg.versionfile_build = get(parser, "versionfile_build") - cfg.tag_prefix = get(parser, "tag_prefix") - if cfg.tag_prefix in ("''", '""'): - cfg.tag_prefix = "" - cfg.parentdir_prefix = get(parser, "parentdir_prefix") - cfg.verbose = get(parser, "verbose") - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -# these dictionaries contain VCS-specific tools -LONG_VERSION_PY = {} -HANDLERS = {} - - -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - - def decorate(f): - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - - return decorate - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): - """Call the given command(s).""" - assert isinstance(commands, list) - p = None - for c in commands: - try: - dispcmd = str([c] + args) - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen( - [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) - ) - break - except OSError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % dispcmd) - print(e) - return None, None - else: - if verbose: - print(f"unable to find command, tried {commands}" - return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %s (error)" % dispcmd) - print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode - - -LONG_VERSION_PY[ - "git" -] = r''' -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (built by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) - -"""Git implementation of _version.py.""" - -import errno -import os -import re -import subprocess -import sys - - -def get_keywords(): - """Get the keywords needed to look up the version information.""" - # these strings will be replaced by git during git-archive. - # setup.py/versioneer.py will grep for the variable names, so they must - # each be defined on a line of their own. _version.py will just call - # get_keywords(). - git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" - git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" - git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" - keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} - return keywords - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - -def get_config(): - """Create, populate and return the VersioneerConfig() object.""" - # these strings are filled in when 'setup.py versioneer' creates - # _version.py - cfg = VersioneerConfig() - cfg.VCS = "git" - cfg.style = "%(STYLE)s" - cfg.tag_prefix = "%(TAG_PREFIX)s" - cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" - cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" - cfg.verbose = False - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -LONG_VERSION_PY = {} -HANDLERS = {} - - -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - return decorate - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): - """Call the given command(s).""" - assert isinstance(commands, list) - p = None - for c in commands: - try: - dispcmd = str([c] + args) - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %%s" %% dispcmd) - print(e) - return None, None - else: - if verbose: - print("unable to find command, tried %%s" %% (commands,)) - return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %%s (error)" %% dispcmd) - print("stdout was %%s" %% stdout) - return None, p.returncode - return stdout, p.returncode - - -def versions_from_parentdir(parentdir_prefix, root, verbose): - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for i in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print("Tried directories %%s but none started with prefix %%s" %% - (str(rootdirs), parentdir_prefix)) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords = {} - try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): - """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") - date = keywords.get("date") - if date is not None: - # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %%d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%%s', no digits" %% ",".join(refs - tags)) - if verbose: - print("likely tags: %%s" %% ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %%s" %% r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) - if rc != 0: - if verbose: - print("Directory %%s not under git control" %% root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%%s*" %% tag_prefix], - cwd=root) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) - if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%%s'" - %% describe_out) - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%%s' doesn't start with prefix '%%s'" - print(fmt %% (full_tag, tag_prefix)) - pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" - %% (full_tag, tag_prefix)) - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) - pieces["distance"] = int(count_out) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], - cwd=root)[0].strip() - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def plus_or_dot(pieces): - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces): - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post.devDISTANCE - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += ".post.dev%%d" %% pieces["distance"] - else: - # exception #1 - rendered = "0.post.dev%%d" %% pieces["distance"] - return rendered - - -def render_pep440_post(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%%s" %% pieces["short"] - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%%s" %% pieces["short"] - return rendered - - -def render_pep440_old(pieces): - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Eexceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces): - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces): - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces, style): - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%%s'" %% style) - - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} - - -def get_versions(): - """Get version information or return default if unable to do so.""" - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded keywords. - - cfg = get_config() - verbose = cfg.verbose - - try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, - verbose) - except NotThisMethod: - pass - - try: - root = os.path.realpath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for i in cfg.versionfile_source.split('/'): - root = os.path.dirname(root) - except NameError: - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None} - - try: - pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) - return render(pieces, cfg.style) - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - except NotThisMethod: - pass - - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", "date": None} -''' - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords = {} - try: - f = open(versionfile_abs) - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except OSError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): - """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") - date = keywords.get("date") - if date is not None: - # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} - if verbose: - print("discarding '%s', no digits" % ",".join(refs - tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] - if verbose: - print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) - if rc != 0: - if verbose: - print("Directory %s not under git control" % root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command( - GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root - ) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) - if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (full_tag, tag_prefix)) - pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def do_vcs_install(manifest_in, versionfile_source, ipy): - """Git-specific installation logic for Versioneer. - - For Git, this means creating/changing .gitattributes to mark _version.py - for export-subst keyword substitution. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - files = [manifest_in, versionfile_source] - if ipy: - files.append(ipy) - try: - me = __file__ - if me.endswith(".pyc") or me.endswith(".pyo"): - me = os.path.splitext(me)[0] + ".py" - versioneer_file = os.path.relpath(me) - except NameError: - versioneer_file = "versioneer.py" - files.append(versioneer_file) - present = False - try: - f = open(".gitattributes") - for line in f.readlines(): - if line.strip().startswith(versionfile_source): - if "export-subst" in line.strip().split()[1:]: - present = True - f.close() - except OSError: - pass - if not present: - f = open(".gitattributes", "a+") - f.write("%s export-subst\n" % versionfile_source) - f.close() - files.append(".gitattributes") - run_command(GITS, ["add", "--"] + files) - - -def versions_from_parentdir(parentdir_prefix, root, verbose): - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for i in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.18) from -# revision-control system data, or from the parent directory name of an -# unpacked source archive. Distribution tarballs contain a pre-generated copy -# of this file. - -import json - -version_json = ''' -%s -''' # END VERSION_JSON - - -def get_versions(): - return json.loads(version_json) -""" - - -def versions_from_file(filename): - """Try to determine the version from _version.py if present.""" - try: - with open(filename) as f: - contents = f.read() - except OSError: - raise NotThisMethod("unable to read _version.py") - mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) - if not mo: - mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) - if not mo: - raise NotThisMethod("no version_json in _version.py") - return json.loads(mo.group(1)) - - -def write_to_version_file(filename, versions): - """Write the given version number to the given _version.py file.""" - os.unlink(filename) - contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) - with open(filename, "w") as f: - f.write(SHORT_VERSION_PY % contents) - - print(f"set {filename} to '{versions['version']}'") - - -def plus_or_dot(pieces): - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces): - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post.devDISTANCE - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] - else: - # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] - return rendered - - -def render_pep440_post(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - return rendered - - -def render_pep440_old(pieces): - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Eexceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces): - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces): - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces, style): - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%s'" % style) - - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } - - -class VersioneerBadRootError(Exception): - """The project root directory is unknown or missing key files.""" - - -def get_versions(verbose=False): - """Get the project version from whatever source is available. - - Returns dict with two keys: 'version' and 'full'. - """ - if "versioneer" in sys.modules: - # see the discussion in cmdclass.py:get_cmdclass() - del sys.modules["versioneer"] - - root = get_root() - cfg = get_config_from_root(root) - - assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" - handlers = HANDLERS.get(cfg.VCS) - assert handlers, "unrecognized VCS '%s'" % cfg.VCS - verbose = verbose or cfg.verbose - assert cfg.versionfile_source is not None, "please set versioneer.versionfile_source" - assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" - - versionfile_abs = os.path.join(root, cfg.versionfile_source) - - # extract version from first of: _version.py, VCS command (e.g. 'git - # describe'), parentdir. This is meant to work for developers using a - # source checkout, for users of a tarball created by 'setup.py sdist', - # and for users of a tarball/zipball created by 'git archive' or github's - # download-from-tag feature or the equivalent in other VCSes. - - get_keywords_f = handlers.get("get_keywords") - from_keywords_f = handlers.get("keywords") - if get_keywords_f and from_keywords_f: - try: - keywords = get_keywords_f(versionfile_abs) - ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) - if verbose: - print("got version from expanded keyword %s" % ver) - return ver - except NotThisMethod: - pass - - try: - ver = versions_from_file(versionfile_abs) - if verbose: - print(f"got version from file {versionfile_abs} {ver}") - return ver - except NotThisMethod: - pass - - from_vcs_f = handlers.get("pieces_from_vcs") - if from_vcs_f: - try: - pieces = from_vcs_f(cfg.tag_prefix, root, verbose) - ver = render(pieces, cfg.style) - if verbose: - print("got version from VCS %s" % ver) - return ver - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - if verbose: - print("got version from parentdir %s" % ver) - return ver - except NotThisMethod: - pass - - if verbose: - print("unable to compute version") - - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } - - -def get_version(): - """Get the short version string for this project.""" - return get_versions()["version"] - - -def get_cmdclass(): - """Get the custom setuptools/distutils subclasses used by Versioneer.""" - if "versioneer" in sys.modules: - del sys.modules["versioneer"] - # this fixes the "python setup.py develop" case (also 'install' and - # 'easy_install .'), in which subdependencies of the main project are - # built (using setup.py bdist_egg) in the same python process. Assume - # a main project A and a dependency B, which use different versions - # of Versioneer. A's setup.py imports A's Versioneer, leaving it in - # sys.modules by the time B's setup.py is executed, causing B to run - # with the wrong versioneer. Setuptools wraps the sub-dep builds in a - # sandbox that restores sys.modules to it's pre-build state, so the - # parent is protected against the child's "import versioneer". By - # removing ourselves from sys.modules here, before the child build - # happens, we protect the child from the parent's versioneer too. - # Also see https://github.com/warner/python-versioneer/issues/52 - - cmds = {} - - # we add "version" to both distutils and setuptools - from distutils.core import Command - - class cmd_version(Command): - description = "report generated version string" - user_options = [] - boolean_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - vers = get_versions(verbose=True) - print("Version: %s" % vers["version"]) - print(" full-revisionid: %s" % vers.get("full-revisionid")) - print(" dirty: %s" % vers.get("dirty")) - print(" date: %s" % vers.get("date")) - if vers["error"]: - print(" error: %s" % vers["error"]) - - cmds["version"] = cmd_version - - # we override "build_py" in both distutils and setuptools - # - # most invocation pathways end up running build_py: - # distutils/build -> build_py - # distutils/install -> distutils/build ->.. - # setuptools/bdist_wheel -> distutils/install ->.. - # setuptools/bdist_egg -> distutils/install_lib -> build_py - # setuptools/install -> bdist_egg ->.. - # setuptools/develop -> ? - # pip install: - # copies source tree to a tempdir before running egg_info/etc - # if .git isn't copied too, 'git describe' will fail - # then does setup.py bdist_wheel, or sometimes setup.py install - # setup.py egg_info -> ? - - # we override different "build_py" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.build_py import build_py as _build_py - else: - from distutils.command.build_py import build_py as _build_py - - class cmd_build_py(_build_py): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - _build_py.run(self) - # now locate _version.py in the new build/ directory and replace - # it with an updated value - if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - cmds["build_py"] = cmd_build_py - - if "cx_Freeze" in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe - - # nczeczulin reports that py2exe won't like the pep440-style string - # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. - # setup(console=[{ - # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION - # "product_version": versioneer.get_version(), - # ... - - class cmd_build_exe(_build_exe): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _build_exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - cmds["build_exe"] = cmd_build_exe - del cmds["build_py"] - - if "py2exe" in sys.modules: # py2exe enabled? - try: - from py2exe.distutils_buildexe import py2exe as _py2exe # py3 - except ImportError: - from py2exe.build_exe import py2exe as _py2exe # py2 - - class cmd_py2exe(_py2exe): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _py2exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - cmds["py2exe"] = cmd_py2exe - - # we override different "sdist" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.sdist import sdist as _sdist - else: - from distutils.command.sdist import sdist as _sdist - - class cmd_sdist(_sdist): - def run(self): - versions = get_versions() - self._versioneer_generated_versions = versions - # unless we update this, the command will keep using the old - # version - self.distribution.metadata.version = versions["version"] - return _sdist.run(self) - - def make_release_tree(self, base_dir, files): - root = get_root() - cfg = get_config_from_root(root) - _sdist.make_release_tree(self, base_dir, files) - # now locate _version.py in the new base_dir directory - # (remembering that it may be a hardlink) and replace it with an - # updated value - target_versionfile = os.path.join(base_dir, cfg.versionfile_source) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, self._versioneer_generated_versions) - - cmds["sdist"] = cmd_sdist - - return cmds - - -CONFIG_ERROR = """ -setup.cfg is missing the necessary Versioneer configuration. You need -a section like: - - [versioneer] - VCS = git - style = pep440 - versionfile_source = src/myproject/_version.py - versionfile_build = myproject/_version.py - tag_prefix = - parentdir_prefix = myproject- - -You will also need to edit your setup.py to use the results: - - import versioneer - setup(version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), ...) - -Please read the docstring in ./versioneer.py for configuration instructions, -edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. -""" - -SAMPLE_CONFIG = """ -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. - -[versioneer] -#VCS = git -#style = pep440 -#versionfile_source = -#versionfile_build = -#tag_prefix = -#parentdir_prefix = - -""" - -INIT_PY_SNIPPET = """ -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions -""" - - -def do_setup(): - """Main VCS-independent setup function for installing Versioneer.""" - root = get_root() - try: - cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: - if isinstance(e, (EnvironmentError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", file=sys.stderr) - with open(os.path.join(root, "setup.cfg"), "a") as f: - f.write(SAMPLE_CONFIG) - print(CONFIG_ERROR, file=sys.stderr) - return 1 - - print(" creating %s" % cfg.versionfile_source) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") - if os.path.exists(ipy): - try: - with open(ipy) as f: - old = f.read() - except OSError: - old = "" - if INIT_PY_SNIPPET not in old: - print(" appending to %s" % ipy) - with open(ipy, "a") as f: - f.write(INIT_PY_SNIPPET) - else: - print(" %s unmodified" % ipy) - else: - print(" %s doesn't exist, ok" % ipy) - ipy = None - - # Make sure both the top-level "versioneer.py" and versionfile_source - # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so - # they'll be copied into source distributions. Pip won't be able to - # install the package without this. - manifest_in = os.path.join(root, "MANIFEST.in") - simple_includes = set() - try: - with open(manifest_in) as f: - for line in f: - if line.startswith("include "): - for include in line.split()[1:]: - simple_includes.add(include) - except OSError: - pass - # That doesn't cover everything MANIFEST.in can do - # (http://docs.python.org/2/distutils/sourcedist.html#commands), so - # it might give some false negatives. Appending redundant 'include' - # lines is safe, though. - if "versioneer.py" not in simple_includes: - print(" appending 'versioneer.py' to MANIFEST.in") - with open(manifest_in, "a") as f: - f.write("include versioneer.py\n") - else: - print(" 'versioneer.py' already in MANIFEST.in") - if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % cfg.versionfile_source) - with open(manifest_in, "a") as f: - f.write("include %s\n" % cfg.versionfile_source) - else: - print(" versionfile_source already in MANIFEST.in") - - # Make VCS-specific changes. For git, this means creating/changing - # .gitattributes to mark _version.py for export-subst keyword - # substitution. - do_vcs_install(manifest_in, cfg.versionfile_source, ipy) - return 0 - - -def scan_setup_py(): - """Validate the contents of setup.py against Versioneer's expectations.""" - found = set() - setters = False - errors = 0 - with open("setup.py") as f: - for line in f.readlines(): - if "import versioneer" in line: - found.add("import") - if "versioneer.get_cmdclass()" in line: - found.add("cmdclass") - if "versioneer.get_version()" in line: - found.add("get_version") - if "versioneer.VCS" in line: - setters = True - if "versioneer.versionfile_source" in line: - setters = True - if len(found) != 3: - print("") - print("Your setup.py appears to be missing some important items") - print("(but I might be wrong). Please make sure it has something") - print("roughly like the following:") - print("") - print(" import versioneer") - print(" setup( version=versioneer.get_version(),") - print(" cmdclass=versioneer.get_cmdclass(), ...)") - print("") - errors += 1 - if setters: - print("You should remove lines like 'versioneer.VCS = ' and") - print("'versioneer.versionfile_source = ' . This configuration") - print("now lives in setup.cfg, and should be removed from setup.py") - print("") - errors += 1 - return errors - - -if __name__ == "__main__": - cmd = sys.argv[1] - if cmd == "setup": - errors = do_setup() - errors += scan_setup_py() - if errors: - sys.exit(1)