From eabcb1bd687bc655b86f3fa874378df92142cbc8 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 10:56:08 -0500
Subject: [PATCH 01/69] Initial commit
---
LICENSE | 661 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
README.md | 1 +
2 files changed, 662 insertions(+)
create mode 100644 LICENSE
create mode 100644 README.md
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8bffba7
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+# tap-google-ads
\ No newline at end of file
From 4015fdc6be8e0500ff531281bc14bf52ffca7e65 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 11:09:40 -0500
Subject: [PATCH 02/69] Create setup.py
---
setup.py | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 setup.py
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..fbc0ccd
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python
+
+from setuptools import setup
+
+setup(name='tap-google-ads',
+ version='0.0.1',
+ description='Singer.io tap for extracting data from the Google Ads API',
+ author='Stitch',
+ url='http://singer.io',
+ classifiers=['Programming Language :: Python :: 3 :: Only'],
+ py_modules=['tap_google_ads'],
+ install_requires=[
+ 'attrs',
+ 'singer-python==5.12.2',
+ 'requests',
+ 'backoff',
+ 'requests_mock',
+ 'google-ads',
+ ],
+ extras_require= {
+ 'dev': [
+ 'pylint',
+ 'nose',
+ 'ipdb',
+ ]
+ },
+ entry_points='''
+ [console_scripts]
+ tap-google-ads=tap_google_ads:main
+ ''',
+ packages=['tap_google_ads'],
+ package_data = {
+ },
+ include_package_data=True,
+)
From 0a3044bb454053dddb1ac609748cc426fd5fc179 Mon Sep 17 00:00:00 2001
From: Chris Merrick
Date: Tue, 30 Nov 2021 11:11:15 -0500
Subject: [PATCH 03/69] Add PR template
---
.github/pull_request_template.md | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 .github/pull_request_template.md
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..6e46b00
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+# Description of change
+(write a short description or paste a link to JIRA)
+
+# Manual QA steps
+ -
+
+# Risks
+ -
+
+# Rollback steps
+ - revert this branch
From 1c1e6ff724f9e2cdb8fe00f837c7a6b818d17004 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 11:13:06 -0500
Subject: [PATCH 04/69] Update README.md
---
README.md | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 8bffba7..08fad02 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,9 @@
-# tap-google-ads
\ No newline at end of file
+# tap_google_ads
+
+This is a [Singer](https://singer.io) tap that produces JSON-formatted
+data from the Google Ads API following the [Singer
+spec](https://github.com/singer-io/getting-started/blob/master/SPEC.md).
+
+---
+
+Copyright © 2021 Stitch
From 31ab4154ff6c26d9503a2c114ec10ec5d7b13d3b Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 11:15:06 -0500
Subject: [PATCH 05/69] Update README.md
---
README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/README.md b/README.md
index 08fad02..4538134 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,10 @@ This is a [Singer](https://singer.io) tap that produces JSON-formatted
data from the Google Ads API following the [Singer
spec](https://github.com/singer-io/getting-started/blob/master/SPEC.md).
+This tap:
+
+- Pulls raw data from the [Google Ads API](https://developers.google.com/google-ads/api/docs/start).
+
---
Copyright © 2021 Stitch
From e342089d33dc5217027be675c1fca8bf8ef984e9 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 11:20:28 -0500
Subject: [PATCH 06/69] Create config.yaml
---
.circleci/config.yaml | 36 ++++++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
create mode 100644 .circleci/config.yaml
diff --git a/.circleci/config.yaml b/.circleci/config.yaml
new file mode 100644
index 0000000..9bedafc
--- /dev/null
+++ b/.circleci/config.yaml
@@ -0,0 +1,36 @@
+version: 2
+jobs:
+ build:
+ docker:
+ - image:
+ steps:
+ - checkout
+ - run:
+ name: 'Setup virtual env'
+ command: |
+ python3 -mvenv /usr/local/share/virtualenvs/tap-google-ads
+ source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
+ pip install -U pip setuptools
+ - run:
+ name: 'pylint'
+ command: |
+ source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
+ # TODO: Adjust the pylint disables
+ pylint tap_google_ads --disable 'broad-except,chained-comparison,empty-docstring,fixme,invalid-name,line-too-long,missing-class-docstring,missing-function-docstring,missing-module-docstring,no-else-raise,no-else-return,too-few-public-methods,too-many-arguments,too-many-branches,too-many-lines,too-many-locals,ungrouped-imports,wrong-spelling-in-comment,wrong-spelling-in-docstring,bad-whitespace,missing-class-docstring'
+workflows:
+ version: 2
+ commit:
+ jobs:
+ - build:
+ context: circleci-user
+ build_daily:
+ triggers:
+ - schedule:
+ cron: "0 13 * * *"
+ filters:
+ branches:
+ only:
+ - master
+ jobs:
+ - build:
+ context: circleci-user
From dea80f2a050a8157fdb5e6d9e04a3030320ffb0e Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 11:22:22 -0500
Subject: [PATCH 07/69] Create __init__.py
---
tap_google_ads/__init__.py | 12 ++++++++++++
1 file changed, 12 insertions(+)
create mode 100644 tap_google_ads/__init__.py
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
new file mode 100644
index 0000000..47ad71b
--- /dev/null
+++ b/tap_google_ads/__init__.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+import json
+import os
+import sys
+
+import singer
+
+def main():
+ pass
+
+if __name__ == "__main__":
+ main()
From e3ea86ac1a57b4f9c7930bcd8803c9d7ae0d7aca Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 13:48:25 -0500
Subject: [PATCH 08/69] updating setup.py to include pinned versions (#1)
---
setup.py | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/setup.py b/setup.py
index fbc0ccd..535b2de 100644
--- a/setup.py
+++ b/setup.py
@@ -10,12 +10,10 @@
classifiers=['Programming Language :: Python :: 3 :: Only'],
py_modules=['tap_google_ads'],
install_requires=[
- 'attrs',
'singer-python==5.12.2',
- 'requests',
- 'backoff',
- 'requests_mock',
- 'google-ads',
+ 'requests==2.26.0',
+ 'backoff==1.11.1',
+ 'google-ads==14.1.0',
],
extras_require= {
'dev': [
From ec8d8922581ddec4f9529634d699928c886fe275 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 30 Nov 2021 14:19:03 -0500
Subject: [PATCH 09/69] Enable and fix circleci config (#2)
* Correct extension for config
* Add version tag
* Add dev intall to config
* Correct Singer Python dependency conflict
* fix indentation for pylint
---
.circleci/{config.yaml => config.yml} | 3 ++-
setup.py | 2 +-
tap_google_ads/__init__.py | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
rename .circleci/{config.yaml => config.yml} (91%)
diff --git a/.circleci/config.yaml b/.circleci/config.yml
similarity index 91%
rename from .circleci/config.yaml
rename to .circleci/config.yml
index 9bedafc..9512769 100644
--- a/.circleci/config.yaml
+++ b/.circleci/config.yml
@@ -2,7 +2,7 @@ version: 2
jobs:
build:
docker:
- - image:
+ - image: 218546966473.dkr.ecr.us-east-1.amazonaws.com/circle-ci:stitch-tap-tester
steps:
- checkout
- run:
@@ -11,6 +11,7 @@ jobs:
python3 -mvenv /usr/local/share/virtualenvs/tap-google-ads
source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
pip install -U pip setuptools
+ pip install -e .[dev]
- run:
name: 'pylint'
command: |
diff --git a/setup.py b/setup.py
index 535b2de..abb25c4 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@
install_requires=[
'singer-python==5.12.2',
'requests==2.26.0',
- 'backoff==1.11.1',
+ 'backoff==1.8.0',
'google-ads==14.1.0',
],
extras_require= {
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 47ad71b..4290b69 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -6,7 +6,7 @@
import singer
def main():
- pass
+ pass
if __name__ == "__main__":
main()
From 33281b1974e73a798e4f1c16795a0f90c65b59e2 Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Mon, 6 Dec 2021 15:26:06 -0500
Subject: [PATCH 10/69] Core table discovery (#3)
* create spikes folder for discovery and add protobuff to setup
* add more fields to campaign schema
* Attempt at getting some kind of auto schema
* Clean up, added comments to links where to look up missing classes, refactored out giant elif blocks
* use refs
* Add note to inspect for enum types
* Add schema files for core objects
* rename files to be consistent with existing integration
* Change Schema folder location and write discovery for core streams
* actually add schemas
* Correct Primary Key for ads stream
* Make Pylint happy
* Delete campaigns.json
* Make pylint happier
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: Dan Mosora <30501696+dmosorast@users.noreply.github.com>
Co-authored-by: Bryant Gray
---
setup.py | 1 +
spikes/campaigns_schema.json | 460 +++++++
spikes/get_campaigns.py | 107 ++
spikes/get_hierarchy.py | 197 +++
spikes/list_accessible_customers.py | 61 +
spikes/schema_gen_protobuf.py | 159 +++
tap_google_ads/__init__.py | 77 +-
tap_google_ads/schemas/accounts.json | 171 +++
tap_google_ads/schemas/ad_groups.json | 268 ++++
tap_google_ads/schemas/ads.json | 1765 +++++++++++++++++++++++++
tap_google_ads/schemas/campaigns.json | 858 ++++++++++++
11 files changed, 4123 insertions(+), 1 deletion(-)
create mode 100644 spikes/campaigns_schema.json
create mode 100644 spikes/get_campaigns.py
create mode 100644 spikes/get_hierarchy.py
create mode 100644 spikes/list_accessible_customers.py
create mode 100644 spikes/schema_gen_protobuf.py
create mode 100644 tap_google_ads/schemas/accounts.json
create mode 100644 tap_google_ads/schemas/ad_groups.json
create mode 100644 tap_google_ads/schemas/ads.json
create mode 100644 tap_google_ads/schemas/campaigns.json
diff --git a/setup.py b/setup.py
index abb25c4..5699664 100644
--- a/setup.py
+++ b/setup.py
@@ -14,6 +14,7 @@
'requests==2.26.0',
'backoff==1.8.0',
'google-ads==14.1.0',
+ 'protobuf==3.17.3',
],
extras_require= {
'dev': [
diff --git a/spikes/campaigns_schema.json b/spikes/campaigns_schema.json
new file mode 100644
index 0000000..277e06e
--- /dev/null
+++ b/spikes/campaigns_schema.json
@@ -0,0 +1,460 @@
+{
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "resourceName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "status": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "adServingOptimizationStatus": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "advertisingChannelType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "networkSettings": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "targetGoogleSearch": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetSearchNetwork": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetContentNetwork": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetPartnerSearchNetwork": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "experimentType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "servingStatus": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "biddingStrategyType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "manualCpc": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "enhancedCpcEnabled": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "geoTargetTypeSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "positiveGeoTargetType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "negativeGeoTargetType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "paymentMode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "baseCampaign": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "name": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "id": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "campaignBudget": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "startDate": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "date-time"
+ },
+ "endDate": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "date-time"
+ },
+ "advertisingChannelSubtype": {
+ "type": [
+ "null",
+ "string"
+ ],
+ },
+ "urlCustomParameters": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "key": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "value": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ },
+ "realTimeBiddingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "optIn": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "hotelSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "hotelCenterId": [
+ "type": [
+ "null",
+ "integer"
+ ]
+ ]
+ }
+ },
+ "dynamicSearchAdsSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "domainName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "languageCode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "feeds": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "use_supplied_urls_only": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "shoppingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "merchantId": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "salesCountry": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "campaignPriority": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "enableLocal": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "targetingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "targetRestrictions": {
+ "type": [
+ "null",
+ "array"
+ ]
+ "items": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "targetingDimension": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "bidOnly": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ }
+ },
+ "targetRestrictionOperations": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "operator": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "targetRestriction" {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "targetingDimension": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "bidOnly": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "localCampaignSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "locationSourceType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "appCampaignSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "biddingStrategyGoalType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "appStore": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "appId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "labels": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "accessibleBiddingStrategy": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "frequencyCaps": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "videoBrandSafetySuitability": {
+ "type": [
+ "null",
+ "sting"
+ ]
+ },
+ "vanityPharma": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "vanityPharmaDisplayUrlMode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "vanityPharmaText": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/spikes/get_campaigns.py b/spikes/get_campaigns.py
new file mode 100644
index 0000000..a5e7f6a
--- /dev/null
+++ b/spikes/get_campaigns.py
@@ -0,0 +1,107 @@
+"""Gets the account hierarchy of the given MCC and login customer ID.
+If you don't specify manager ID and login customer ID, the example will instead
+print the hierarchies of all accessible customer accounts for your
+authenticated Google account. Note that if the list of accessible customers for
+your authenticated Google account includes accounts within the same hierarchy,
+this example will retrieve and print the overlapping portions of the hierarchy
+for each accessible customer.
+"""
+
+import argparse
+import sys
+import json
+
+from google.ads.googleads.client import GoogleAdsClient
+from google.ads.googleads.errors import GoogleAdsException
+from google.protobuf.json_format import MessageToDict
+from google.protobuf.json_format import MessageToJson
+
+def main(client, login_customer_id=None):
+ """Gets the account hierarchy of the given MCC and login customer ID.
+ Args:
+ client: The Google Ads client.
+ login_customer_id: Optional manager account ID. If none provided, this
+ method will instead list the accounts accessible from the
+ authenticated Google Ads account.
+ """
+
+ # Gets instances of the GoogleAdsService and CustomerService clients.
+ googleads_service = client.get_service("GoogleAdsService")
+ customer_service = client.get_service("CustomerService")
+ campaign_service = client.get_service("CampaignService")
+
+ # A collection of customer IDs to handle.
+ seed_customer_ids = [4224806558]
+ #Manager
+ #login_customer_id = '9837412151'
+ #Non Manager
+ #login_customer_id = '4224806558'
+
+
+ # Creates a query that retrieves all child accounts of the manager
+ # specified in search calls below.
+ query = """
+ SELECT
+ campaign.resource_name
+ FROM campaign
+ """
+
+ for seed_customer_id in seed_customer_ids:
+ # Performs a breadth-first search to build a Dictionary that maps
+ # managers to their child accounts (customerIdsToChildAccounts).
+ unprocessed_customer_ids = [seed_customer_id]
+ customer_ids_to_child_accounts = dict()
+ root_customer_client = None
+
+ while unprocessed_customer_ids:
+ customer_id = int(unprocessed_customer_ids.pop(0))
+ response = googleads_service.search(
+ customer_id=str(customer_id), query=query
+ )
+ #all_campaigns = json.load(response)
+ all_campaigns = []
+
+ for googleads_row in response:
+ all_campaigns.append(googleads_row.campaign.resource_name)
+
+ for resource_name in all_campaigns:
+ response2 = campaign_service.get_campaign(resource_name=resource_name)
+ json_response = MessageToJson(response2)
+ print(json_response)
+ break
+
+if __name__ == "__main__":
+ # GoogleAdsClient will read the google-ads.yaml configration file in the
+ # home directory if none is specified.
+ googleads_client = GoogleAdsClient.load_from_storage(version="v9")
+
+ parser = argparse.ArgumentParser(
+ description="This example gets the account hierarchy of the specified "
+ "manager account and login customer ID."
+ )
+ # The following argument(s) should be provided to run the example.
+ parser.add_argument(
+ "-l",
+ "--login_customer_id",
+ "--manager_customer_id",
+ type=str,
+ required=False,
+ help="Optional manager "
+ "account ID. If none provided, the example will "
+ "instead list the accounts accessible from the "
+ "authenticated Google Ads account.",
+ )
+ args = parser.parse_args()
+ try:
+ main(googleads_client, args.login_customer_id)
+ except GoogleAdsException as ex:
+ print(
+ f'Request with ID "{ex.request_id}" failed with status '
+ f'"{ex.error.code().name}" and includes the following errors:'
+ )
+ for error in ex.failure.errors:
+ print(f'\tError with message "{error.message}".')
+ if error.location:
+ for field_path_element in error.location.field_path_elements:
+ print(f"\t\tOn field: {field_path_element.field_name}")
+ sys.exit(1)
diff --git a/spikes/get_hierarchy.py b/spikes/get_hierarchy.py
new file mode 100644
index 0000000..ebd903b
--- /dev/null
+++ b/spikes/get_hierarchy.py
@@ -0,0 +1,197 @@
+"""Gets the account hierarchy of the given MCC and login customer ID.
+If you don't specify manager ID and login customer ID, the example will instead
+print the hierarchies of all accessible customer accounts for your
+authenticated Google account. Note that if the list of accessible customers for
+your authenticated Google account includes accounts within the same hierarchy,
+this example will retrieve and print the overlapping portions of the hierarchy
+for each accessible customer.
+"""
+
+import argparse
+import sys
+
+from google.ads.googleads.client import GoogleAdsClient
+from google.ads.googleads.errors import GoogleAdsException
+
+
+def main(client, login_customer_id=None):
+ """Gets the account hierarchy of the given MCC and login customer ID.
+ Args:
+ client: The Google Ads client.
+ login_customer_id: Optional manager account ID. If none provided, this
+ method will instead list the accounts accessible from the
+ authenticated Google Ads account.
+ """
+
+ # Gets instances of the GoogleAdsService and CustomerService clients.
+ googleads_service = client.get_service("GoogleAdsService")
+ customer_service = client.get_service("CustomerService")
+
+ # A collection of customer IDs to handle.
+ seed_customer_ids = []
+
+ # Creates a query that retrieves all child accounts of the manager
+ # specified in search calls below.
+ query = """
+ SELECT
+ customer_client.client_customer,
+ customer_client.level,
+ customer_client.manager,
+ customer_client.descriptive_name,
+ customer_client.currency_code,
+ customer_client.time_zone,
+ customer_client.id
+ FROM customer_client
+ WHERE customer_client.level <= 1"""
+
+ # If a Manager ID was provided in the customerId parameter, it will be
+ # the only ID in the list. Otherwise, we will issue a request for all
+ # customers accessible by this authenticated Google account.
+ if login_customer_id is not None:
+ seed_customer_ids = [login_customer_id]
+ else:
+ print(
+ "No manager ID is specified. The example will print the "
+ "hierarchies of all accessible customer IDs."
+ )
+
+ customer_resource_names = (
+ customer_service.list_accessible_customers().resource_names
+ )
+
+ for customer_resource_name in customer_resource_names:
+ if '1585293495' not in customer_resource_name:
+ customer = customer_service.get_customer(
+ resource_name=customer_resource_name
+ )
+ print(customer.id)
+ seed_customer_ids.append(customer.id)
+
+ for seed_customer_id in seed_customer_ids:
+ # Performs a breadth-first search to build a Dictionary that maps
+ # managers to their child accounts (customerIdsToChildAccounts).
+ unprocessed_customer_ids = [seed_customer_id]
+ customer_ids_to_child_accounts = dict()
+ root_customer_client = None
+
+ while unprocessed_customer_ids:
+ customer_id = int(unprocessed_customer_ids.pop(0))
+ response = googleads_service.search(
+ customer_id=str(customer_id), query=query
+ )
+
+ # Iterates over all rows in all pages to get all customer
+ # clients under the specified customer's hierarchy.
+ for googleads_row in response:
+ customer_client = googleads_row.customer_client
+
+ # The customer client that with level 0 is the specified
+ # customer.
+ if customer_client.level == 0:
+ if root_customer_client is None:
+ root_customer_client = customer_client
+ continue
+
+ # For all level-1 (direct child) accounts that are a
+ # manager account, the above query will be run against them
+ # to create a Dictionary of managers mapped to their child
+ # accounts for printing the hierarchy afterwards.
+ if customer_id not in customer_ids_to_child_accounts:
+ customer_ids_to_child_accounts[customer_id] = []
+
+ customer_ids_to_child_accounts[customer_id].append(
+ customer_client
+ )
+
+ if customer_client.manager:
+ # A customer can be managed by multiple managers, so to
+ # prevent visiting the same customer many times, we
+ # need to check if it's already in the Dictionary.
+ if (
+ customer_client.id not in customer_ids_to_child_accounts
+ and customer_client.level == 1
+ ):
+ unprocessed_customer_ids.append(customer_client.id)
+
+ if root_customer_client is not None:
+ print(
+ "The hierarchy of customer ID "
+ f"{root_customer_client.id} is printed below:"
+ )
+ _print_account_hierarchy(
+ root_customer_client, customer_ids_to_child_accounts, 0
+ )
+ else:
+ print(
+ f"Customer ID {login_customer_id} is likely a test "
+ "account, so its customer client information cannot be "
+ "retrieved."
+ )
+
+
+def _print_account_hierarchy(
+ customer_client, customer_ids_to_child_accounts, depth
+):
+ """Prints the specified account's hierarchy using recursion.
+ Args:
+ customer_client: The customer cliant whose info will be printed; its
+ child accounts will be processed if it's a manager.
+ customer_ids_to_child_accounts: A dictionary mapping customer IDs to
+ child accounts.
+ depth: The current integer depth we are printing from in the account
+ hierarchy.
+ """
+ if depth == 0:
+ print("Customer ID (Descriptive Name, Currency Code, Time Zone)")
+
+ customer_id = customer_client.id
+ print("-" * (depth * 2), end="")
+ print(
+ f"{customer_id} ({customer_client.descriptive_name}, "
+ f"{customer_client.currency_code}, "
+ f"{customer_client.time_zone})"
+ )
+
+ # Recursively call this function for all child accounts of customer_client.
+ if customer_id in customer_ids_to_child_accounts:
+ for child_account in customer_ids_to_child_accounts[customer_id]:
+ _print_account_hierarchy(
+ child_account, customer_ids_to_child_accounts, depth + 1
+ )
+
+
+if __name__ == "__main__":
+ # GoogleAdsClient will read the google-ads.yaml configration file in the
+ # home directory if none is specified.
+ googleads_client = GoogleAdsClient.load_from_storage(version="v9")
+
+ parser = argparse.ArgumentParser(
+ description="This example gets the account hierarchy of the specified "
+ "manager account and login customer ID."
+ )
+ # The following argument(s) should be provided to run the example.
+ parser.add_argument(
+ "-l",
+ "--login_customer_id",
+ "--manager_customer_id",
+ type=str,
+ required=False,
+ help="Optional manager "
+ "account ID. If none provided, the example will "
+ "instead list the accounts accessible from the "
+ "authenticated Google Ads account.",
+ )
+ args = parser.parse_args()
+ try:
+ main(googleads_client, args.login_customer_id)
+ except GoogleAdsException as ex:
+ print(
+ f'Request with ID "{ex.request_id}" failed with status '
+ f'"{ex.error.code().name}" and includes the following errors:'
+ )
+ for error in ex.failure.errors:
+ print(f'\tError with message "{error.message}".')
+ if error.location:
+ for field_path_element in error.location.field_path_elements:
+ print(f"\t\tOn field: {field_path_element.field_name}")
+ sys.exit(1)
diff --git a/spikes/list_accessible_customers.py b/spikes/list_accessible_customers.py
new file mode 100644
index 0000000..d202075
--- /dev/null
+++ b/spikes/list_accessible_customers.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python
+# Copyright 2018 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""This example lists the resource names for the customers that the
+authenticating user has access to.
+
+The customer IDs retrieved from the resource names can be used to set
+the login-customer-id configuration. For more information see this
+documentation: https://developers.google.com/google-ads/api/docs/concepts/call-structure#cid
+"""
+
+
+import sys
+
+from google.ads.googleads.client import GoogleAdsClient
+from google.ads.googleads.errors import GoogleAdsException
+
+
+# [START list_accessible_customers]
+def main(client):
+ customer_service = client.get_service("CustomerService")
+
+ accessible_customers = customer_service.list_accessible_customers()
+ result_total = len(accessible_customers.resource_names)
+ print(f"Total results: {result_total}")
+
+ resource_names = accessible_customers.resource_names
+ for resource_name in resource_names:
+ print(f'Customer resource name: "{resource_name}"')
+ # [END list_accessible_customers]
+
+
+if __name__ == "__main__":
+ # GoogleAdsClient will read the google-ads.yaml configuration file in the
+ # home directory if none is specified.
+ googleads_client = GoogleAdsClient.load_from_storage(version="v9")
+
+ try:
+ main(googleads_client)
+ except GoogleAdsException as ex:
+ print(
+ f'Request with ID "{ex.request_id}" failed with status '
+ f'"{ex.error.code().name}" and includes the following errors:'
+ )
+ for error in ex.failure.errors:
+ print(f'\tError with message "{error.message}".')
+ if error.location:
+ for field_path_element in error.location.field_path_elements:
+ print(f"\t\tOn field: {field_path_element.field_name}")
+ sys.exit(1)
diff --git a/spikes/schema_gen_protobuf.py b/spikes/schema_gen_protobuf.py
new file mode 100644
index 0000000..0e076cc
--- /dev/null
+++ b/spikes/schema_gen_protobuf.py
@@ -0,0 +1,159 @@
+import importlib
+import json
+import os
+import pkgutil
+import re
+from google.ads.googleads.v9.resources.types import campaign, ad, ad_group, customer
+from google.protobuf.pyext.cpp_message import GeneratedProtocolMessageType
+from google.protobuf.pyext._message import RepeatedScalarContainer, RepeatedCompositeContainer
+
+#>>> type(campaign.Campaign()._pb.target_spend.__class__)
+#
+
+# Unknown types lookup to their actual class in the code. For some reason these differ.
+# Unknown classes should be found in this project, likely somehwere around here:
+# https://github.com/googleads/google-ads-python/tree/14.1.0/google/ads/googleads/v9/common/types
+type_lookup = {"google.ads.googleads.v9.common.FinalAppUrl": "google.ads.googleads.v9.common.types.final_app_url.FinalAppUrl",
+ "google.ads.googleads.v9.common.AdVideoAsset": "google.ads.googleads.v9.common.types.ad_asset.AdVideoAsset",
+ "google.ads.googleads.v9.common.AdTextAsset": "google.ads.googleads.v9.common.types.ad_asset.AdTextAsset",
+ "google.ads.googleads.v9.common.AdMediaBundleAsset": "google.ads.googleads.v9.common.types.ad_asset.AdMediaBundleAsset",
+ "google.ads.googleads.v9.common.AdImageAsset": "google.ads.googleads.v9.common.types.ad_asset.AdImageAsset",
+
+ "google.ads.googleads.v9.common.PolicyTopicEntry": "google.ads.googleads.v9.common.types.policy.PolicyTopicEntry",
+ "google.ads.googleads.v9.common.PolicyTopicConstraint": "google.ads.googleads.v9.common.types.policy.PolicyTopicConstraint",
+ "google.ads.googleads.v9.common.PolicyTopicEvidence": "google.ads.googleads.v9.common.types.policy.PolicyTopicEvidence",
+ "google.ads.googleads.v9.common.PolicyTopicConstraint.CountryConstraint": "google.ads.googleads.v9.common.types.policy.PolicyTopicConstraint", # This one's weird, handling it manually in the generator
+
+ "google.ads.googleads.v9.common.UrlCollection": "google.ads.googleads.v9.common.types.url_collection.UrlCollection",
+
+ "google.ads.googleads.v9.common.CustomParameter": "google.ads.googleads.v9.common.types.custom_parameter.CustomParameter",
+
+ "google.ads.googleads.v9.common.ProductImage": "google.ads.googleads.v9.common.types.ad_type_infos.ProductImage",
+ "google.ads.googleads.v9.common.ProductVideo": "google.ads.googleads.v9.common.types.ad_type_infos.ProductVideo",
+
+ "google.ads.googleads.v9.common.FrequencyCapEntry": "google.ads.googleads.v9.common.types.frequency_cap.FrequencyCapEntry",
+
+ "google.ads.googleads.v9.common.TargetRestriction": "google.ads.googleads.v9.common.types.targeting_setting.TargetRestriction",
+ "google.ads.googleads.v9.common.TargetRestrictionOperation": "google.ads.googleads.v9.common.types.targeting_setting.TargetRestrictionOperation",
+ }
+
+# From: https://stackoverflow.com/questions/19053707/converting-snake-case-to-lower-camel-case-lowercamelcase
+def to_camel_case(snake_str):
+ components = snake_str.split('_')
+ # We capitalize the first letter of each component except the first one
+ # with the 'title' method and join them together.
+ return components[0] + ''.join(x.title() for x in components[1:])
+
+def type_to_json_schema(typ):
+ # TODO: Bytes in an anyOf gives us, usually, just 'string', so it can be a non-anyOf?
+ if typ == 'bytes':
+ return {"type": ["null","UNSUPPORTED_string"]}
+ elif typ == 'int':
+ return {"type": ["null","integer"]}
+ elif typ in ['str','unicode']:
+ return {"type": ["null","string"]}
+ elif typ == 'long':
+ return {"type": ["null","integer"]}
+ else:
+ raise Exception(f"Unknown scalar type {typ}")
+
+def handle_scalar_container(acc, prop_val, prop_camel):
+ try:
+ prop_val.append(1)
+ prop_val.append(True)
+ prop_val.append(0.0)
+ except TypeError as e:
+ re_result = re.search(r"but expected one of: (.+)$", str(e))
+ if re_result:
+ actual_types = re_result.groups()[0].split(',')
+ actual_types = [t.strip() for t in actual_types]
+ acc[prop_camel] = {"type": ["null", "array"],
+ "items": {"anyOf": [type_to_json_schema(t) for t in actual_types]}}
+ else:
+ raise
+
+ref_schema_lookup = {}
+def handle_composite_container(acc, prop_val, prop_camel):
+ try:
+ prop_val.append(1)
+ prop_val.append(True)
+ prop_val.append(0.0)
+ except TypeError as e:
+ re_result = re.search(r"expected (.+) got ", str(e))
+ if not re_result:
+ import ipdb; ipdb.set_trace()
+ 1+1
+ raise
+ shown_type = re_result.groups()[0]
+ actual_type = type_lookup.get(shown_type)
+ if not actual_type:
+ print(f"Unknown composite type: {shown_type}")
+ else:
+ # TODO: Should we just build the objects based on the type name and insert them as a definition and $ref to save space?
+ mod = importlib.import_module('.'.join(actual_type.split('.')[:-1]))
+ if shown_type == "google.ads.googleads.v9.common.PolicyTopicConstraint.CountryConstraint":
+ obj = getattr(mod, actual_type.split('.')[-1]).CountryConstraint()
+ else:
+ obj = getattr(mod, actual_type.split('.')[-1])()
+ type_name = shown_type.split('.')[-1]
+ acc[prop_camel] = {"type": ["null", "array"],
+ "items":{"$ref": f"#/definitions/{type_name}"}}
+ if type_name not in ref_schema_lookup:
+ ref_schema_lookup[type_name] = get_schema({},obj._pb)
+
+def get_schema(acc, current):
+ for prop in filter(lambda p: re.search(r"^[a-z]", p), dir(current)):
+ try:
+ prop_val = getattr(current, prop)
+ prop_camel = to_camel_case(prop)
+ if isinstance(prop_val.__class__, GeneratedProtocolMessageType):
+ # TODO: Should we just build the objects based on the type name and insert them as a definition and $ref to save space?
+ new_acc_obj = {}
+ type_name = type(prop_val).__qualname__
+ acc[prop_camel] = {"$ref": f"#/definitions/{type_name}"}
+ if type_name not in ref_schema_lookup:
+ ref_schema_lookup[type_name] = {"type": ["null", "object"],
+ "properties": get_schema(new_acc_obj, prop_val)}
+ elif isinstance(prop_val, bool):
+ acc[prop_camel] = {"type": ["null", "boolean"]}
+ elif isinstance(prop_val, str):
+ acc[prop_camel] = {"type": ["null", "string"]}
+ elif isinstance(prop_val, int):
+ acc[prop_camel] = {"type": ["null", "integer"]}
+ elif isinstance(prop_val, float):
+ acc[prop_camel] = {"type": ["null", "string"],
+ "format": "singer.decimal"}
+ elif isinstance(prop_val, bytes):
+ # TODO: Should this just be empty? Then put it elsewhere to mark as unsupported? With a message?
+ # - Or should we just make it string?
+ acc[prop_camel] = {"type": ["null", "UNSUPPORTED_string"]}
+ elif isinstance(prop_val, RepeatedScalarContainer):
+ handle_scalar_container(acc, prop_val, prop_camel)
+ elif isinstance(prop_val, RepeatedCompositeContainer):
+ handle_composite_container(acc, prop_val, prop_camel)
+ else:
+ import ipdb; ipdb.set_trace()
+ 1+1
+ raise Exception(f"Unhandled type {type(prop_val)}")
+ except Exception as e:
+ raise
+ #import ipdb; ipdb.set_trace()
+ # 1+1
+ return acc
+
+def root_get_schema(obj, pb):
+ schema = get_schema(obj, pb)
+ global ref_schema_lookup
+ schema["definitions"] = ref_schema_lookup
+ ref_schema_lookup = {}
+ return schema
+
+with open("auto_campaign.json", "w") as f:
+ json.dump(root_get_schema({}, campaign.Campaign()._pb), f)
+with open("auto_ad.json", "w") as f:
+ json.dump(root_get_schema({}, ad.Ad()._pb), f)
+with open("auto_ad_group.json", "w") as f:
+ json.dump(root_get_schema({}, ad_group.AdGroup()._pb), f)
+with open("auto_account.json", "w") as f:
+ json.dump(root_get_schema({}, customer.Customer()._pb), f)
+print("Wrote schemas to local directory under auto_*.json, please review and manually set datetime formats on datetime fields and change Enum field types to 'string' schema.")
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 4290b69..2d5e3e6 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -4,9 +4,84 @@
import sys
import singer
+from singer import utils
+from singer import metrics
+from singer import bookmarks
+from singer import metadata
+from singer import (transform,
+ UNIX_MILLISECONDS_INTEGER_DATETIME_PARSING,
+ Transformer)
+
+from google.ads.googleads.client import GoogleAdsClient
+from google.ads.googleads.errors import GoogleAdsException
+from google.protobuf.json_format import MessageToJson
+
+
+LOGGER = singer.get_logger()
+CORE_ENDPOINT_MAPPINGS = {"campaigns": {'primary_keys': ["id"],
+ 'service_name': 'CampaignService'},
+ "ad_groups": {'primary_keys': ["id"],
+ 'service_name': 'AdGroupService'},
+ "ads": {'primary_keys': ["id"],
+ 'service_name': 'AdGroupAdService'},
+ "accounts": {'primary_keys': ["customerId"],
+ 'service_name': 'ManagedCustomerService'}}
+
+def create_field_metadata(stream, schema):
+ primary_key = CORE_ENDPOINT_MAPPINGS[stream]['primary_keys']
+
+ mdata = {}
+ mdata = metadata.write(mdata, (), 'inclusion', 'available')
+ mdata = metadata.write(mdata, (), 'table-key-properties', primary_key)
+
+ for field in schema['properties']:
+ breadcrumb = ('properties', str(field))
+ mdata = metadata.write(mdata, breadcrumb, 'inclusion', 'available')
+
+ mdata = metadata.write(mdata, ('properties', primary_key[0]), 'inclusion', 'automatic')
+ mdata = metadata.to_list(mdata)
+
+ return mdata
+
+def get_abs_path(path):
+ return os.path.join(os.path.dirname(os.path.realpath(__file__)), path)
+
+def load_schema(entity):
+ return utils.load_json(get_abs_path(f"schemas/{entity}.json"))
+
+def load_metadata(entity):
+ return utils.load_json(get_abs_path(f"metadata/{entity}.json"))
+
+def do_discover_core_endpoints():
+ streams = []
+ LOGGER.info("Starting core discovery")
+ for stream_name in CORE_ENDPOINT_MAPPINGS:
+ LOGGER.info('Loading schema for %s', stream_name)
+ schema = load_schema(stream_name)
+ md = create_field_metadata(stream_name, schema)
+ streams.append({'stream': stream_name,
+ 'tap_stream_id': stream_name,
+ 'schema': schema,
+ 'metadata': md})
+ LOGGER.info("Core discovery complete")
+ return streams
+
+def do_discover():
+ #sdk_client = create_sdk_client()
+ core_streams = do_discover_core_endpoints()
+ # report_streams = do_discover_reports(sdk_client)
+ streams = []
+ streams.extend(core_streams)
+ # streams.extend(report_streams)
+ json.dump({"streams": streams}, sys.stdout, indent=2)
+
+def create_sdk_client():
+ CONFIG = {}
+ sdk_client = GoogleAdsClient.load_from_dict(CONFIG)
+ return sdk_client
def main():
- pass
+ do_discover()
if __name__ == "__main__":
main()
diff --git a/tap_google_ads/schemas/accounts.json b/tap_google_ads/schemas/accounts.json
new file mode 100644
index 0000000..b422ac9
--- /dev/null
+++ b/tap_google_ads/schemas/accounts.json
@@ -0,0 +1,171 @@
+{
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "autoTaggingEnabled": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "callReportingSetting": {
+ "$ref": "#/definitions/CallReportingSetting"
+ },
+ "conversionTrackingSetting": {
+ "$ref": "#/definitions/ConversionTrackingSetting"
+ },
+ "currencyCode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "descriptiveName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "finalUrlSuffix": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "hasPartnersBadge": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "id": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "manager": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "optimizationScore": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "singer.decimal"
+ },
+ "optimizationScoreWeight": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "singer.decimal"
+ },
+ "payPerConversionEligibilityFailureReasons": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "remarketingSetting": {
+ "$ref": "#/definitions/RemarketingSetting"
+ },
+ "resourceName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "testAccount": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "timeZone": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "trackingUrlTemplate": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "definitions": {
+ "CallReportingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "callConversionAction": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "callConversionReportingEnabled": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "callReportingEnabled": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "ConversionTrackingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "conversionTrackingId": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "crossAccountConversionTrackingId": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "RemarketingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "googleGlobalSiteTag": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tap_google_ads/schemas/ad_groups.json b/tap_google_ads/schemas/ad_groups.json
new file mode 100644
index 0000000..9a21ac7
--- /dev/null
+++ b/tap_google_ads/schemas/ad_groups.json
@@ -0,0 +1,268 @@
+{
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "adRotationMode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "baseAdGroup": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "campaign": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "cpcBidMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "cpmBidMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "cpvBidMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "displayCustomBidDimension": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "effectiveTargetCpaMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "effectiveTargetCpaSource": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "effectiveTargetRoas": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "singer.decimal"
+ },
+ "effectiveTargetRoasSource": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "excludedParentAssetFieldTypes": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "explorerAutoOptimizerSetting": {
+ "$ref": "#/definitions/ExplorerAutoOptimizerSetting"
+ },
+ "finalUrlSuffix": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "id": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "labels": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "name": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "percentCpcBidMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "resourceName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "status": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "targetCpaMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "targetCpmMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "targetRoas": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "singer.decimal"
+ },
+ "targetingSetting": {
+ "$ref": "#/definitions/TargetingSetting"
+ },
+ "trackingUrlTemplate": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "type": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "urlCustomParameters": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/CustomParameter"
+ }
+ },
+ "definitions": {
+ "ExplorerAutoOptimizerSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "optIn": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "TargetRestriction": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "bidOnly": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetingDimension": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "TargetRestrictionOperation": {
+ "operator": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "value": {
+ "$ref": "#/definitions/TargetRestriction"
+ }
+ },
+ "TargetingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "targetRestrictionOperations": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/TargetRestrictionOperation"
+ }
+ },
+ "targetRestrictions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/TargetRestriction"
+ }
+ }
+ }
+ },
+ "CustomParameter": {
+ "key": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "value": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/tap_google_ads/schemas/ads.json b/tap_google_ads/schemas/ads.json
new file mode 100644
index 0000000..d4bcda5
--- /dev/null
+++ b/tap_google_ads/schemas/ads.json
@@ -0,0 +1,1765 @@
+{
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "addedByGoogleAds": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "appAd": {
+ "$ref": "#/definitions/AppAdInfo"
+ },
+ "appEngagementAd": {
+ "$ref": "#/definitions/AppEngagementAdInfo"
+ },
+ "appPreRegistrationAd": {
+ "$ref": "#/definitions/AppPreRegistrationAdInfo"
+ },
+ "callAd": {
+ "$ref": "#/definitions/CallAdInfo"
+ },
+ "devicePreference": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "displayUploadAd": {
+ "$ref": "#/definitions/DisplayUploadAdInfo"
+ },
+ "displayUrl": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "expandedDynamicSearchAd": {
+ "$ref": "#/definitions/ExpandedDynamicSearchAdInfo"
+ },
+ "expandedTextAd": {
+ "$ref": "#/definitions/ExpandedTextAdInfo"
+ },
+ "finalAppUrls": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/FinalAppUrl"
+ }
+ },
+ "finalMobileUrls": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "finalUrlSuffix": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "finalUrls": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "gmailAd": {
+ "$ref": "#/definitions/GmailAdInfo"
+ },
+ "hotelAd": {
+ "$ref": "#/definitions/HotelAdInfo"
+ },
+ "id": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "imageAd": {
+ "$ref": "#/definitions/ImageAdInfo"
+ },
+ "legacyAppInstallAd": {
+ "$ref": "#/definitions/LegacyAppInstallAdInfo"
+ },
+ "legacyResponsiveDisplayAd": {
+ "$ref": "#/definitions/LegacyResponsiveDisplayAdInfo"
+ },
+ "localAd": {
+ "$ref": "#/definitions/LocalAdInfo"
+ },
+ "name": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "resourceName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "responsiveDisplayAd": {
+ "$ref": "#/definitions/ResponsiveDisplayAdInfo"
+ },
+ "responsiveSearchAd": {
+ "$ref": "#/definitions/ResponsiveSearchAdInfo"
+ },
+ "shoppingComparisonListingAd": {
+ "$ref": "#/definitions/ShoppingComparisonListingAdInfo"
+ },
+ "shoppingProductAd": {
+ "$ref": "#/definitions/ShoppingProductAdInfo"
+ },
+ "shoppingSmartAd": {
+ "$ref": "#/definitions/ShoppingSmartAdInfo"
+ },
+ "smartCampaignAd": {
+ "$ref": "#/definitions/SmartCampaignAdInfo"
+ },
+ "systemManagedResourceSource": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "textAd": {
+ "$ref": "#/definitions/TextAdInfo"
+ },
+ "trackingUrlTemplate": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "type": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "urlCollections": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/UrlCollection"
+ }
+ },
+ "urlCustomParameters": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/CustomParameter"
+ }
+ },
+ "videoAd": {
+ "$ref": "#/definitions/VideoAdInfo"
+ },
+ "videoResponsiveAd": {
+ "$ref": "#/definitions/VideoResponsiveAdInfo"
+ },
+ "definitions": {
+ "CountryConstraint": {
+ "countryCriterion": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "CountryConstraintList": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "countries": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/CountryConstraint"
+ }
+ },
+ "totalTargetedCountries": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "ResellerConstraint": {
+ "type": [
+ "null",
+ "object"
+ ]
+ },
+ "PolicyTopicConstraint": {
+ "certificateDomainMismatchInCountryList": {
+ "$ref": "#/definitions/CountryConstraintList"
+ },
+ "certificateMissingInCountryList": {
+ "$ref": "#/definitions/CountryConstraintList"
+ },
+ "countryConstraintList": {
+ "$ref": "#/definitions/CountryConstraintList"
+ },
+ "resellerConstraint": {
+ "$ref": "#/definitions/ResellerConstraint"
+ }
+ },
+ "DestinationMismatch": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "urlTypes": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ }
+ },
+ "DestinationNotWorking": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "device": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "dnsErrorType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "expandedUrl": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "httpErrorCode": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "lastCheckedDateTime": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "date-time"
+ }
+ }
+ },
+ "DestinationTextList": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "destinationTexts": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ },
+ "TextList": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "texts": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ },
+ "WebsiteList": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "websites": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ },
+ "PolicyTopicEvidence": {
+ "destinationMismatch": {
+ "$ref": "#/definitions/DestinationMismatch"
+ },
+ "destinationNotWorking": {
+ "$ref": "#/definitions/DestinationNotWorking"
+ },
+ "destinationTextList": {
+ "$ref": "#/definitions/DestinationTextList"
+ },
+ "languageCode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "textList": {
+ "$ref": "#/definitions/TextList"
+ },
+ "websiteList": {
+ "$ref": "#/definitions/WebsiteList"
+ }
+ },
+ "PolicyTopicEntry": {
+ "constraints": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/PolicyTopicConstraint"
+ }
+ },
+ "evidences": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/PolicyTopicEvidence"
+ }
+ },
+ "topic": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "type": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "AdAssetPolicySummary": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "approvalStatus": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "policyTopicEntries": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/PolicyTopicEntry"
+ }
+ },
+ "reviewStatus": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "AdTextAsset": {
+ "assetPerformanceLabel": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "pinnedField": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "policySummaryInfo": {
+ "$ref": "#/definitions/AdAssetPolicySummary"
+ },
+ "text": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "AdMediaBundleAsset": {
+ "asset": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "AdImageAsset": {
+ "asset": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "AdVideoAsset": {
+ "asset": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "AppAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "html5MediaBundles": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdMediaBundleAsset"
+ }
+ },
+ "images": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "mandatoryAdText": {
+ "$ref": "#/definitions/AdTextAsset"
+ },
+ "youtubeVideos": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdVideoAsset"
+ }
+ }
+ }
+ },
+ "AppEngagementAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "images": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "videos": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdVideoAsset"
+ }
+ }
+ }
+ },
+ "AppPreRegistrationAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "images": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "youtubeVideos": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdVideoAsset"
+ }
+ }
+ }
+ },
+ "CallAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "businessName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "callTracked": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "conversionAction": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "conversionReportingState": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "countryCode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "disableCallConversion": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "headline1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headline2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "path1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "path2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "phoneNumber": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "phoneNumberVerificationUrl": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "DisplayUploadAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "displayUploadProductType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "mediaBundle": {
+ "$ref": "#/definitions/AdMediaBundleAsset"
+ }
+ }
+ },
+ "ExpandedDynamicSearchAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "description": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "ExpandedTextAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "description": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headlinePart1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headlinePart2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headlinePart3": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "path1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "path2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "FinalAppUrl": {
+ "osType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "url": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "DisplayCallToAction": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "text": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "textColor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "urlCollectionId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "ProductImage": {
+ "description": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "displayCallToAction": {
+ "$ref": "#/definitions/DisplayCallToAction"
+ },
+ "productImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "ProductVideo": {
+ "productVideo": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "GmailTeaser": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "businessName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "logoImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "GmailAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "headerImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketingImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketingImageDescription": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketingImageDisplayCallToAction": {
+ "$ref": "#/definitions/DisplayCallToAction"
+ },
+ "marketingImageHeadline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "productImages": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/ProductImage"
+ }
+ },
+ "productVideos": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/ProductVideo"
+ }
+ },
+ "teaser": {
+ "$ref": "#/definitions/GmailTeaser"
+ }
+ }
+ },
+ "HotelAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ]
+ },
+ "ImageAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "adIdToCopyImageFrom": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "data": {
+ "type": [
+ "null",
+ "UNSUPPORTED_string"
+ ]
+ },
+ "imageUrl": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "mediaFile": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "mimeType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "name": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "pixelHeight": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "pixelWidth": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "previewImageUrl": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "previewPixelHeight": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "previewPixelWidth": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "LegacyAppInstallAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "appId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "appStore": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "LegacyResponsiveDisplayAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "accentColor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "allowFlexibleColor": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "businessName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "callToActionText": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "formatSetting": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "logoImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "longHeadline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "mainColor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketingImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "pricePrefix": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "promoText": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "shortHeadline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "squareLogoImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "squareMarketingImage": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "LocalAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "callToActions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "logoImages": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "marketingImages": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "path1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "path2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "videos": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdVideoAsset"
+ }
+ }
+ }
+ },
+ "ResponsiveDisplayAdControlSpec": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "enableAssetEnhancements": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "enableAutogenVideo": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "ResponsiveDisplayAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "accentColor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "allowFlexibleColor": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "businessName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "callToActionText": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "controlSpec": {
+ "$ref": "#/definitions/ResponsiveDisplayAdControlSpec"
+ },
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "formatSetting": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "logoImages": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "longHeadline": {
+ "$ref": "#/definitions/AdTextAsset"
+ },
+ "mainColor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "marketingImages": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "pricePrefix": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "promoText": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "squareLogoImages": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "squareMarketingImages": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "youtubeVideos": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdVideoAsset"
+ }
+ }
+ }
+ },
+ "ResponsiveSearchAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "path1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "path2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "ShoppingComparisonListingAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "headline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "ShoppingProductAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": null
+ },
+ "ShoppingSmartAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ]
+ },
+ "SmartCampaignAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ }
+ }
+ },
+ "TextAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "description1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "UrlCollection": {
+ "finalMobileUrls": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "finalUrls": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "trackingUrlTemplate": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "urlCollectionId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "CustomParameter": {
+ "key": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "value": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "VideoBumperInStreamAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "companionBanner": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ }
+ },
+ "VideoTrueViewDiscoveryAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "description1": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "description2": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "thumbnail": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "VideoTrueViewInStreamAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "actionButtonLabel": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "actionHeadline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "companionBanner": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ }
+ },
+ "VideoNonSkippableInStreamAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "actionButtonLabel": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "actionHeadline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "companionBanner": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ }
+ },
+ "VideoOutstreamAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "description": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "headline": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "VideoAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "bumper": {
+ "$ref": "#/definitions/VideoBumperInStreamAdInfo"
+ },
+ "discovery": {
+ "$ref": "#/definitions/VideoTrueViewDiscoveryAdInfo"
+ },
+ "inStream": {
+ "$ref": "#/definitions/VideoTrueViewInStreamAdInfo"
+ },
+ "nonSkippable": {
+ "$ref": "#/definitions/VideoNonSkippableInStreamAdInfo"
+ },
+ "outStream": {
+ "$ref": "#/definitions/VideoOutstreamAdInfo"
+ },
+ "video": {
+ "$ref": "#/definitions/AdVideoAsset"
+ }
+ }
+ },
+ "VideoResponsiveAdInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "callToActions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "companionBanners": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdImageAsset"
+ }
+ },
+ "descriptions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "headlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "longHeadlines": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdTextAsset"
+ }
+ },
+ "videos": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/AdVideoAsset"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tap_google_ads/schemas/campaigns.json b/tap_google_ads/schemas/campaigns.json
new file mode 100644
index 0000000..b4cdcad
--- /dev/null
+++ b/tap_google_ads/schemas/campaigns.json
@@ -0,0 +1,858 @@
+{
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "accessibleBiddingStrategy": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "adServingOptimizationStatus": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "advertisingChannelSubType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "advertisingChannelType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "appCampaignSetting": {
+ "$ref": "#/definitions/AppCampaignSetting"
+ },
+ "baseCampaign": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "biddingStrategy": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "biddingStrategyType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "campaignBudget": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "commission": {
+ "$ref": "#/definitions/Commission"
+ },
+ "dynamicSearchAdsSetting": {
+ "$ref": "#/definitions/DynamicSearchAdsSetting"
+ },
+ "endDate": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "date-time"
+ },
+ "excludedParentAssetFieldTypes": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "experimentType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "finalUrlSuffix": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "frequencyCaps": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/FrequencyCapEntry"
+ }
+ },
+ "geoTargetTypeSetting": {
+ "$ref": "#/definitions/GeoTargetTypeSetting"
+ },
+ "hotelSetting": {
+ "$ref": "#/definitions/HotelSettingInfo"
+ },
+ "id": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "labels": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "localCampaignSetting": {
+ "$ref": "#/definitions/LocalCampaignSetting"
+ },
+ "manualCpc": {
+ "$ref": "#/definitions/ManualCpc"
+ },
+ "manualCpm": {
+ "$ref": "#/definitions/ManualCpm"
+ },
+ "manualCpv": {
+ "$ref": "#/definitions/ManualCpv"
+ },
+ "maximizeConversionValue": {
+ "$ref": "#/definitions/MaximizeConversionValue"
+ },
+ "maximizeConversions": {
+ "$ref": "#/definitions/MaximizeConversions"
+ },
+ "name": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "networkSettings": {
+ "$ref": "#/definitions/NetworkSettings"
+ },
+ "optimizationGoalSetting": {
+ "$ref": "#/definitions/OptimizationGoalSetting"
+ },
+ "optimizationScore": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "singer.decimal"
+ },
+ "paymentMode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "percentCpc": {
+ "$ref": "#/definitions/PercentCpc"
+ },
+ "realTimeBiddingSetting": {
+ "$ref": "#/definitions/RealTimeBiddingSetting"
+ },
+ "resourceName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "selectiveOptimization": {
+ "$ref": "#/definitions/SelectiveOptimization"
+ },
+ "servingStatus": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "shoppingSetting": {
+ "$ref": "#/definitions/ShoppingSetting"
+ },
+ "startDate": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "date-time"
+ },
+ "status": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "targetCpa": {
+ "$ref": "#/definitions/TargetCpa"
+ },
+ "targetCpm": {
+ "$ref": "#/definitions/TargetCpm"
+ },
+ "targetImpressionShare": {
+ "$ref": "#/definitions/TargetImpressionShare"
+ },
+ "targetRoas": {
+ "$ref": "#/definitions/TargetRoas"
+ },
+ "targetSpend": {
+ "$ref": "#/definitions/TargetSpend"
+ },
+ "targetingSetting": {
+ "$ref": "#/definitions/TargetingSetting"
+ },
+ "trackingSetting": {
+ "$ref": "#/definitions/TrackingSetting"
+ },
+ "trackingUrlTemplate": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "urlCustomParameters": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/CustomParameter"
+ }
+ },
+ "urlExpansionOptOut": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "vanityPharma": {
+ "$ref": "#/definitions/VanityPharma"
+ },
+ "videoBrandSafetySuitability": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "definitions": {
+ "AppCampaignSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "appId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "appStore": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "biddingStrategyGoalType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "Commission": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "commissionRateMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "DynamicSearchAdsSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "domainName": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "feeds": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "languageCode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "useSuppliedUrlsOnly": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "FrequencyCapKey": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "eventType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "level": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "timeLength": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "timeUnit": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "FrequencyCapEntry": {
+ "cap": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "key": {
+ "$ref": "#/definitions/FrequencyCapKey"
+ }
+ },
+ "GeoTargetTypeSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "negativeGeoTargetType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "positiveGeoTargetType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "HotelSettingInfo": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "hotelCenterId": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "LocalCampaignSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "locationSourceType": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "ManualCpc": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "enhancedCpcEnabled": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "ManualCpm": {
+ "type": [
+ "null",
+ "object"
+ ]
+ },
+ "ManualCpv": {
+ "type": [
+ "null",
+ "object"
+ ]
+ },
+ "MaximizeConversionValue": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "cpcBidCeilingMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "cpcBidFloorMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "targetRoas": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "singer.decimal"
+ }
+ }
+ },
+ "MaximizeConversions": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "cpcBidCeilingMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "cpcBidFloorMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "targetCpa": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "NetworkSettings": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "targetContentNetwork": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetGoogleSearch": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetPartnerSearchNetwork": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetSearchNetwork": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "OptimizationGoalSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "optimizationGoalTypes": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ }
+ },
+ "PercentCpc": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "cpcBidCeilingMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "enhancedCpcEnabled": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "RealTimeBiddingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "optIn": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ }
+ }
+ },
+ "SelectiveOptimization": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "conversionActions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ },
+ "ShoppingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "campaignPriority": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "enableLocal": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "merchantId": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "salesCountry": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "TargetCpa": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "cpcBidCeilingMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "cpcBidFloorMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "targetCpaMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "TargetCpm": {
+ "type": [
+ "null",
+ "object"
+ ]
+ },
+ "TargetImpressionShare": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "cpcBidCeilingMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "location": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "locationFractionMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "TargetRoas": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "cpcBidCeilingMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "cpcBidFloorMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "targetRoas": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "format": "singer.decimal"
+ }
+ }
+ },
+ "TargetSpend": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "cpcBidCeilingMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ },
+ "targetSpendMicros": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ },
+ "TargetRestriction": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "bidOnly": {
+ "type": [
+ "null",
+ "boolean"
+ ]
+ },
+ "targetingDimension": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "TargetRestrictionOperation": {
+ "operator": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "value": {
+ "$ref": "#/definitions/TargetRestriction"
+ }
+ },
+ "TargetingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "targetRestrictionOperations": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/TargetRestrictionOperation"
+ }
+ },
+ "targetRestrictions": {
+ "type": [
+ "null",
+ "array"
+ ],
+ "items": {
+ "$ref": "#/definitions/TargetRestriction"
+ }
+ }
+ }
+ },
+ "TrackingSetting": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "trackingUrl": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "CustomParameter": {
+ "key": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "value": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ },
+ "VanityPharma": {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "vanityPharmaDisplayUrlMode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "vanityPharmaText": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ }
+ }
+ }
+}
From a40def03618ae56a59f1006cd3e945b026004f0f Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Mon, 6 Dec 2021 16:00:32 -0500
Subject: [PATCH 11/69] Remove unsupported bytes field from ads schema (#4)
Co-authored-by: Bryant Gray
---
tap_google_ads/schemas/ads.json | 6 ------
1 file changed, 6 deletions(-)
diff --git a/tap_google_ads/schemas/ads.json b/tap_google_ads/schemas/ads.json
index d4bcda5..13780e7 100644
--- a/tap_google_ads/schemas/ads.json
+++ b/tap_google_ads/schemas/ads.json
@@ -977,12 +977,6 @@
"integer"
]
},
- "data": {
- "type": [
- "null",
- "UNSUPPORTED_string"
- ]
- },
"imageUrl": {
"type": [
"null",
From 6b6c96ac3623e7003ac05077979bf1e9b4ad753d Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 7 Dec 2021 14:01:34 -0500
Subject: [PATCH 12/69] Change CORE_ENDPOINT_MAPPINGS primary_key for accounts
stream (#5)
---
tap_google_ads/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 2d5e3e6..43c771b 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -24,7 +24,7 @@
'service_name': 'AdGroupService'},
"ads": {'primary_keys': ["id"],
'service_name': 'AdGroupAdService'},
- "accounts": {'primary_keys': ["customerId"],
+ "accounts": {'primary_keys': ["id"],
'service_name': 'ManagedCustomerService'}}
def create_field_metadata(stream, schema):
From 48c40c4c5e13a4352151f54e76e59c4267028a94 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Tue, 7 Dec 2021 16:57:01 -0500
Subject: [PATCH 13/69] Add required config, args parser, and accept discover
arg (#6)
---
tap_google_ads/__init__.py | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 43c771b..2275781 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -18,6 +18,16 @@
LOGGER = singer.get_logger()
+
+REQUIRED_CONFIG_KEYS = [
+ "start_date",
+ "oauth_client_id",
+ "oauth_client_secret",
+ "refresh_token",
+ "customer_ids",
+ "developer_token",
+]
+
CORE_ENDPOINT_MAPPINGS = {"campaigns": {'primary_keys': ["id"],
'service_name': 'CampaignService'},
"ad_groups": {'primary_keys': ["id"],
@@ -63,7 +73,6 @@ def do_discover_core_endpoints():
'tap_stream_id': stream_name,
'schema': schema,
'metadata': md})
- LOGGER.info("Core discovery complete")
return streams
def do_discover():
@@ -81,7 +90,11 @@ def create_sdk_client():
return sdk_client
def main():
- do_discover()
+ args = utils.parse_args(REQUIRED_CONFIG_KEYS)
+
+ if args.discover:
+ do_discover()
+ LOGGER.info("Discovery complete")
if __name__ == "__main__":
main()
From 4ff64d4414773896dc303ef3e6dcf5383eeccf91 Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Wed, 8 Dec 2021 12:33:37 -0500
Subject: [PATCH 14/69] Qa/setup (#7)
* adding base test
* adding discovery
* run discovery test in circle
Co-authored-by: Manoj Kumar Anand
---
.circleci/config.yml | 33 ++-
tap_google_ads/tests/base.py | 434 +++++++++++++++++++++++++++++
tests/base.py | 431 ++++++++++++++++++++++++++++
tests/test_google_ads_discovery.py | 132 +++++++++
4 files changed, 1025 insertions(+), 5 deletions(-)
create mode 100644 tap_google_ads/tests/base.py
create mode 100644 tests/base.py
create mode 100644 tests/test_google_ads_discovery.py
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 9512769..988bb78 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,4 +1,7 @@
-version: 2
+version: 2.1
+orbs:
+ slack: circleci/slack@3.4.2
+
jobs:
build:
docker:
@@ -8,6 +11,7 @@ jobs:
- run:
name: 'Setup virtual env'
command: |
+ aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
python3 -mvenv /usr/local/share/virtualenvs/tap-google-ads
source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
pip install -U pip setuptools
@@ -18,20 +22,39 @@ jobs:
source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
# TODO: Adjust the pylint disables
pylint tap_google_ads --disable 'broad-except,chained-comparison,empty-docstring,fixme,invalid-name,line-too-long,missing-class-docstring,missing-function-docstring,missing-module-docstring,no-else-raise,no-else-return,too-few-public-methods,too-many-arguments,too-many-branches,too-many-lines,too-many-locals,ungrouped-imports,wrong-spelling-in-comment,wrong-spelling-in-docstring,bad-whitespace,missing-class-docstring'
+ # TODO implement this run block when tests are avialable!
+ # - run:
+ # name: 'Unit Tests'
+ # command: |
+ # source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
+ # nosetests tests/unittests
+ - run:
+ name: 'Integration Tests'
+ command: |
+ source dev_env.sh
+ source /usr/local/share/virtualenvs/tap-tester/bin/activate
+ run-test --tap=tap-google-ads tests
+ - slack/notify-on-failure:
+ only_for_branches: main
+
workflows:
version: 2
commit:
jobs:
- build:
- context: circleci-user
+ context:
+ - circleci-user
+ - tap-tester-user
build_daily:
triggers:
- schedule:
- cron: "0 13 * * *"
+ cron: "0 3 * * *"
filters:
branches:
only:
- - master
+ - main
jobs:
- build:
- context: circleci-user
+ context:
+ - circleci-user
+ - tap-tester-user
diff --git a/tap_google_ads/tests/base.py b/tap_google_ads/tests/base.py
new file mode 100644
index 0000000..30424bf
--- /dev/null
+++ b/tap_google_ads/tests/base.py
@@ -0,0 +1,434 @@
+"""
+Setup expectations for test sub classes
+Run discovery for as a prerequisite for most tests
+"""
+import unittest
+import os
+from datetime import timedelta
+from datetime import datetime as dt
+
+from tap_tester import connections, menagerie, runner
+
+
+class GoogleAdsBase(unittest.TestCase):
+ """
+ Setup expectations for test sub classes.
+ Metadata describing streams.
+
+ A bunch of shared methods that are used in tap-tester tests.
+ Shared tap-specific methods (as needed).
+ """
+ AUTOMATIC_FIELDS = "automatic"
+ REPLICATION_KEYS = "valid-replication-keys"
+ PRIMARY_KEYS = "table-key-properties"
+ FOREIGN_KEYS = "table-foreign-key-properties"
+ REPLICATION_METHOD = "forced-replication-method"
+ INCREMENTAL = "INCREMENTAL"
+ FULL_TABLE = "FULL_TABLE"
+ START_DATE_FORMAT = "%Y-%m-%dT00:00:00Z"
+ REPLICATION_KEY_FORMAT = "%Y-%m-%dT00:00:00.000000Z"
+
+ start_date = ""
+
+ @staticmethod
+ def tap_name():
+ """The name of the tap"""
+ return "tap-google-ads"
+
+ @staticmethod
+ def get_type():
+ """the expected url route ending"""
+ return "platform.google-ads"
+
+ def get_properties(self):
+ #our test data is on the 9/15
+ return {'start_date': '2018-04-12T00:00:00Z',
+ 'end_date': '2018-04-15T00:00:00Z',
+ 'conversion_window_days' : '-1',
+ 'user_id': 'not used?',
+ 'customer_ids': os.getenv('TAP_ADWORDS_CUSTOMER_IDS')}
+
+ @staticmethod
+ def get_credentials(self):
+ return {'developer_token': os.getenv('TAP_ADWORDS_DEVELOPER_TOKEN'),
+ 'oauth_client_id': os.getenv('TAP_ADWORDS_OAUTH_CLIENT_ID'),
+ 'oauth_client_secret': os.getenv('TAP_ADWORDS_OAUTH_CLIENT_SECRET'),
+ 'refresh_token': os.getenv('TAP_ADWORDS_REFRESH_TOKEN')}
+
+ def expected_metadata(self):
+ """The expected streams and metadata about the streams"""
+
+ return {
+ # Core Objects
+ "Accounts": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.FULL,
+ },
+ "Campaigns": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.FULL,
+ },
+ "AdGroups": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.FULL,
+ },
+ "Ads": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.FULL,
+ },
+ # Standard Reports
+ "ACCOUNT_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "ADGROUP_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "AD_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "AGE_RANGE_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "AUDIENCE_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "CALL_METRICS_CALL_DETAILS_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "CAMPAIGN_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "CLICK_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "CRITERIA_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "DISPLAY_KEYWORD_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "DISPLAY_TOPICS_PERFORMANCE_REPORTFINAL_URL_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "GENDER_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "GEO_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "KEYWORDLESS_QUERY_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "KEYWORDS_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "PLACEHOLDER_FEED_ITEM_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "PLACEHOLDER_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "PLACEMENT_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "SEARCH_QUERY_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "SHOPPING_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "VIDEO_PERFORMANCE_REPORT": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ # Custom Reports TODO
+ }
+
+
+
+ def expected_sync_streams(self):
+ """A set of expected stream names"""
+ return set(self.expected_metadata().keys())
+
+ # TODO confirm whether or not these apply for
+ # core objects ?
+ # report objects ?
+ # def child_streams(self):
+ # """
+ # Return a set of streams that are child streams
+ # based on having foreign key metadata
+ # """
+ # return {stream for stream, metadata in self.expected_metadata().items()
+ # if metadata.get(self.FOREIGN_KEYS)}
+
+ def expected_primary_keys(self):
+ """
+ return a dictionary with key of table name
+ and value as a set of primary key fields
+ """
+ return {table: properties.get(self.PRIMARY_KEYS, set())
+ for table, properties
+ in self.expected_metadata().items()}
+
+ def expected_replication_keys(self):
+ """
+ return a dictionary with key of table name
+ and value as a set of replication key fields
+ """
+ return {table: properties.get(self.REPLICATION_KEYS, set())
+ for table, properties
+ in self.expected_metadata().items()}
+
+ def expected_automatic_fields(self):
+ auto_fields = {}
+ for k, v in self.expected_metadata().items():
+ auto_fields[k] = v.get(self.PRIMARY_KEYS, set()) | v.get(self.REPLICATION_KEYS, set())
+
+ return auto_fields
+
+ def expected_replication_method(self):
+ """return a dictionary with key of table name nd value of replication method"""
+ return {table: properties.get(self.REPLICATION_METHOD, None)
+ for table, properties
+ in self.expected_metadata().items()}
+
+
+ def setUp(self):
+ missing_envs = [x for x in [os.getenv('TAP_ADWORDS_DEVELOPER_TOKEN'),
+ os.getenv('TAP_ADWORDS_OAUTH_CLIENT_ID'),
+ os.getenv('TAP_ADWORDS_OAUTH_CLIENT_SECRET'),
+ os.getenv('TAP_ADWORDS_REFRESH_TOKEN'),
+ os.getenv('TAP_ADWORDS_CUSTOMER_IDS')] if x == None]
+ if len(missing_envs) != 0:
+ raise Exception("Missing environment variables: {}".format(missing_envs))
+
+
+ #########################
+ # Helper Methods #
+ #########################
+
+ def run_and_verify_check_mode(self, conn_id):
+ """
+ Run the tap in check mode and verify it succeeds.
+ This should be ran prior to field selection and initial sync.
+
+ Return the connection id and found catalogs from menagerie.
+ """
+ # run in check mode
+ check_job_name = runner.run_check_mode(self, conn_id)
+
+ # verify check exit codes
+ exit_status = menagerie.get_exit_status(conn_id, check_job_name)
+ menagerie.verify_check_exit_status(self, exit_status, check_job_name)
+
+ found_catalogs = menagerie.get_catalogs(conn_id)
+ self.assertGreater(len(found_catalogs), 0, msg="unable to locate schemas for connection {}".format(conn_id))
+
+ found_catalog_names = set(map(lambda c: c['stream_name'], found_catalogs))
+
+ self.assertSetEqual(self.expected_sync_streams(), found_catalog_names, msg="discovered schemas do not match")
+ print("discovered schemas are OK")
+
+ return found_catalogs
+
+ def run_and_verify_sync(self, conn_id):
+ """
+ Run a sync job and make sure it exited properly.
+ Return a dictionary with keys of streams synced
+ and values of records synced for each stream
+ """
+ # Run a sync job using orchestrator
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ # Verify tap and target exit codes
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # Verify actual rows were synced
+ sync_record_count = runner.examine_target_output_file(
+ self, conn_id, self.expected_sync_streams(), self.expected_primary_keys())
+ self.assertGreater(
+ sum(sync_record_count.values()), 0,
+ msg="failed to replicate any data: {}".format(sync_record_count)
+ )
+ print("total replicated row count: {}".format(sum(sync_record_count.values())))
+
+ return sync_record_count
+
+
+ # TODO we may need to account for exclusion rules
+ def perform_and_verify_table_and_field_selection(self, conn_id, test_catalogs,
+ select_default_fields: bool = True,
+ select_pagination_fields: bool = False):
+ """
+ Perform table and field selection based off of the streams to select
+ set and field selection parameters. Note that selecting all fields is not
+ possible for this tap due to dimension/metric conflicts set by Google and
+ enforced by the Stitch UI.
+
+ Verify this results in the expected streams selected and all or no
+ fields selected for those streams.
+ """
+
+ # Select all available fields or select no fields from all testable streams
+ self._select_streams_and_fields(
+ conn_id=conn_id, catalogs=test_catalogs,
+ select_default_fields=select_default_fields,
+ select_pagination_fields=select_pagination_fields
+ )
+
+ catalogs = menagerie.get_catalogs(conn_id)
+
+ # Ensure our selection affects the catalog
+ expected_selected_streams = [tc.get('stream_name') for tc in test_catalogs]
+ expected_default_fields = self.expected_default_fields()
+ expected_pagination_fields = self.expected_pagination_fields()
+ for cat in catalogs:
+ catalog_entry = menagerie.get_annotated_schema(conn_id, cat['stream_id'])
+
+ # Verify all intended streams are selected
+ selected = catalog_entry['metadata'][0]['metadata'].get('selected')
+ print("Validating selection on {}: {}".format(cat['stream_name'], selected))
+ if cat['stream_name'] not in expected_selected_streams:
+ self.assertFalse(selected, msg="Stream selected, but not testable.")
+ continue # Skip remaining assertions if we aren't selecting this stream
+ self.assertTrue(selected, msg="Stream not selected.")
+
+ # collect field selection expecationas
+ expected_automatic_fields = self.expected_automatic_fields()[cat['stream_name']]
+ selected_default_fields = expected_default_fields[cat['stream_name']] if select_default_fields else set()
+ selected_pagination_fields = expected_pagination_fields[cat['stream_name']] if select_pagination_fields else set()
+
+ # Verify all intended fields within the stream are selected
+ expected_selected_fields = expected_automatic_fields | selected_default_fields | selected_pagination_fields
+ selected_fields = self._get_selected_fields_from_metadata(catalog_entry['metadata'])
+ for field in expected_selected_fields:
+ field_selected = field in selected_fields
+ print("\tValidating field selection on {}.{}: {}".format(cat['stream_name'], field, field_selected))
+ self.assertSetEqual(expected_selected_fields, selected_fields)
+
+ @staticmethod
+ def _get_selected_fields_from_metadata(metadata):
+ selected_fields = set()
+ for field in metadata:
+ is_field_metadata = len(field['breadcrumb']) > 1
+ inclusion_automatic_or_selected = (
+ field['metadata']['selected'] is True or \
+ field['metadata']['inclusion'] == 'automatic'
+ )
+ if is_field_metadata and inclusion_automatic_or_selected:
+ selected_fields.add(field['breadcrumb'][1])
+ return selected_fields
+
+ def _select_streams_and_fields(self, conn_id, catalogs, select_default_fields, select_pagination_fields):
+ """Select all streams and all fields within streams"""
+
+ for catalog in catalogs:
+
+ schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
+ metadata = schema_and_metadata['metadata']
+
+ properties = set(md['breadcrumb'][-1] for md in metadata
+ if len(md['breadcrumb']) > 0 and md['breadcrumb'][0] == 'properties')
+
+ # get a list of all properties so that none are selected
+ if select_default_fields:
+ non_selected_properties = properties.difference(
+ self.expected_default_fields()[catalog['stream_name']]
+ )
+ elif select_pagination_fields:
+ non_selected_properties = properties.difference(
+ self.expected_pagination_fields()[catalog['stream_name']]
+ )
+ else:
+ non_selected_properties = properties
+
+ connections.select_catalog_and_fields_via_metadata(
+ conn_id, catalog, schema_and_metadata, [], non_selected_properties)
+
+ @staticmethod
+ def parse_date(date_value):
+ """
+ Pass in string-formatted-datetime, parse the value, and return it as an unformatted datetime object.
+ """
+ date_formats = {
+ "%Y-%m-%dT%H:%M:%S.%fZ",
+ "%Y-%m-%dT%H:%M:%SZ",
+ "%Y-%m-%dT%H:%M:%S.%f+00:00",
+ "%Y-%m-%dT%H:%M:%S+00:00",
+ "%Y-%m-%d"
+ }
+ for date_format in date_formats:
+ try:
+ date_stripped = dt.strptime(date_value, date_format)
+ return date_stripped
+ except ValueError:
+ continue
+
+ raise NotImplementedError("Tests do not account for dates of this format: {}".format(date_value))
+
+ def timedelta_formatted(self, dtime, days=0):
+ try:
+ date_stripped = dt.strptime(dtime, self.START_DATE_FORMAT)
+ return_date = date_stripped + timedelta(days=days)
+
+ return dt.strftime(return_date, self.START_DATE_FORMAT)
+
+ except ValueError:
+ try:
+ date_stripped = dt.strptime(dtime, self.REPLICATION_KEY_FORMAT)
+ return_date = date_stripped + timedelta(days=days)
+
+ return dt.strftime(return_date, self.REPLICATION_KEY_FORMAT)
+
+ except ValueError:
+ return Exception("Datetime object is not of the format: {}".format(self.START_DATE_FORMAT))
+
+ ##########################################################################
+ ### Tap Specific Methods
+ ##########################################################################
+
+ # TODO exclusion rules
+
+ # TODO core objects vs reports
diff --git a/tests/base.py b/tests/base.py
new file mode 100644
index 0000000..0b8953b
--- /dev/null
+++ b/tests/base.py
@@ -0,0 +1,431 @@
+"""
+Setup expectations for test sub classes
+Run discovery for as a prerequisite for most tests
+"""
+import unittest
+import os
+from datetime import timedelta
+from datetime import datetime as dt
+
+from tap_tester import connections, menagerie, runner
+
+
+class GoogleAdsBase(unittest.TestCase):
+ """
+ Setup expectations for test sub classes.
+ Metadata describing streams.
+
+ A bunch of shared methods that are used in tap-tester tests.
+ Shared tap-specific methods (as needed).
+ """
+ AUTOMATIC_FIELDS = "automatic"
+ REPLICATION_KEYS = "valid-replication-keys"
+ PRIMARY_KEYS = "table-key-properties"
+ FOREIGN_KEYS = "table-foreign-key-properties"
+ REPLICATION_METHOD = "forced-replication-method"
+ INCREMENTAL = "INCREMENTAL"
+ FULL_TABLE = "FULL_TABLE"
+ START_DATE_FORMAT = "%Y-%m-%dT00:00:00Z"
+ REPLICATION_KEY_FORMAT = "%Y-%m-%dT00:00:00.000000Z"
+
+ start_date = ""
+
+ @staticmethod
+ def tap_name():
+ """The name of the tap"""
+ return "tap-google-ads"
+
+ @staticmethod
+ def get_type():
+ """the expected url route ending"""
+ return "platform.google-ads"
+
+ def get_properties(self):
+ #our test data is on the 9/15
+ return {'start_date': '2018-04-12T00:00:00Z',
+ # 'end_date': '2018-04-15T00:00:00Z',
+ 'user_id': 'not used?',
+ 'customer_ids': os.getenv('TAP_ADWORDS_CUSTOMER_IDS')}
+
+ def get_credentials(self):
+ return {'developer_token': os.getenv('TAP_ADWORDS_DEVELOPER_TOKEN'),
+ 'oauth_client_id': os.getenv('TAP_ADWORDS_OAUTH_CLIENT_ID'),
+ 'oauth_client_secret': os.getenv('TAP_ADWORDS_OAUTH_CLIENT_SECRET'),
+ 'refresh_token': os.getenv('TAP_ADWORDS_REFRESH_TOKEN')}
+
+ def expected_metadata(self):
+ """The expected streams and metadata about the streams"""
+
+ return {
+ # Core Objects
+ "accounts": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ },
+ "campaigns": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ },
+ "ad_groups": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ },
+ "ads": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ }
+ # # Standard Reports
+ # "ACCOUNT_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "ADGROUP_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "AD_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "AGE_RANGE_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "AUDIENCE_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "CALL_METRICS_CALL_DETAILS_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "CAMPAIGN_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "CLICK_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "CRITERIA_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "DISPLAY_KEYWORD_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "DISPLAY_TOPICS_PERFORMANCE_REPORTFINAL_URL_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "GENDER_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "GEO_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "KEYWORDLESS_QUERY_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "KEYWORDS_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "PLACEHOLDER_FEED_ITEM_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "PLACEHOLDER_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "PLACEMENT_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "SEARCH_QUERY_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "SHOPPING_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # "VIDEO_PERFORMANCE_REPORT": {
+ # self.PRIMARY_KEYS: {"TODO"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
+ # # Custom Reports TODO
+ }
+
+
+
+ def expected_streams(self):
+ """A set of expected stream names"""
+ return set(self.expected_metadata().keys())
+
+ # TODO confirm whether or not these apply for
+ # core objects ?
+ # report objects ?
+ # def child_streams(self):
+ # """
+ # Return a set of streams that are child streams
+ # based on having foreign key metadata
+ # """
+ # return {stream for stream, metadata in self.expected_metadata().items()
+ # if metadata.get(self.FOREIGN_KEYS)}
+
+ def expected_primary_keys(self):
+ """
+ return a dictionary with key of table name
+ and value as a set of primary key fields
+ """
+ return {table: properties.get(self.PRIMARY_KEYS, set())
+ for table, properties
+ in self.expected_metadata().items()}
+
+ def expected_replication_keys(self):
+ """
+ return a dictionary with key of table name
+ and value as a set of replication key fields
+ """
+ return {table: properties.get(self.REPLICATION_KEYS, set())
+ for table, properties
+ in self.expected_metadata().items()}
+
+ def expected_automatic_fields(self):
+ auto_fields = {}
+ for k, v in self.expected_metadata().items():
+ auto_fields[k] = v.get(self.PRIMARY_KEYS, set()) | v.get(self.REPLICATION_KEYS, set())
+
+ return auto_fields
+
+ def expected_replication_method(self):
+ """return a dictionary with key of table name nd value of replication method"""
+ return {table: properties.get(self.REPLICATION_METHOD, None)
+ for table, properties
+ in self.expected_metadata().items()}
+
+
+ def setUp(self):
+ missing_envs = [x for x in [os.getenv('TAP_ADWORDS_DEVELOPER_TOKEN'),
+ os.getenv('TAP_ADWORDS_OAUTH_CLIENT_ID'),
+ os.getenv('TAP_ADWORDS_OAUTH_CLIENT_SECRET'),
+ os.getenv('TAP_ADWORDS_REFRESH_TOKEN'),
+ os.getenv('TAP_ADWORDS_CUSTOMER_IDS')] if x == None]
+ if len(missing_envs) != 0:
+ raise Exception("Missing environment variables: {}".format(missing_envs))
+
+
+ #########################
+ # Helper Methods #
+ #########################
+
+ def run_and_verify_check_mode(self, conn_id):
+ """
+ Run the tap in check mode and verify it succeeds.
+ This should be ran prior to field selection and initial sync.
+
+ Return the connection id and found catalogs from menagerie.
+ """
+ # run in check mode
+ check_job_name = runner.run_check_mode(self, conn_id)
+
+ # verify check exit codes
+ exit_status = menagerie.get_exit_status(conn_id, check_job_name)
+ menagerie.verify_check_exit_status(self, exit_status, check_job_name)
+
+ found_catalogs = menagerie.get_catalogs(conn_id)
+ self.assertGreater(len(found_catalogs), 0, msg="unable to locate schemas for connection {}".format(conn_id))
+
+ found_catalog_names = {found_catalog['stream_name'] for found_catalog in found_catalogs}
+ self.assertSetEqual(self.expected_streams(), found_catalog_names, msg="discovered schemas do not match")
+ print("discovered schemas are OK")
+
+ return found_catalogs
+
+ def run_and_verify_sync(self, conn_id):
+ """
+ Run a sync job and make sure it exited properly.
+ Return a dictionary with keys of streams synced
+ and values of records synced for each stream
+ """
+ # Run a sync job using orchestrator
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ # Verify tap and target exit codes
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # Verify actual rows were synced
+ sync_record_count = runner.examine_target_output_file(
+ self, conn_id, self.expected_sync_streams(), self.expected_primary_keys())
+ self.assertGreater(
+ sum(sync_record_count.values()), 0,
+ msg="failed to replicate any data: {}".format(sync_record_count)
+ )
+ print("total replicated row count: {}".format(sum(sync_record_count.values())))
+
+ return sync_record_count
+
+
+ # TODO we may need to account for exclusion rules
+ def perform_and_verify_table_and_field_selection(self, conn_id, test_catalogs,
+ select_default_fields: bool = True,
+ select_pagination_fields: bool = False):
+ """
+ Perform table and field selection based off of the streams to select
+ set and field selection parameters. Note that selecting all fields is not
+ possible for this tap due to dimension/metric conflicts set by Google and
+ enforced by the Stitch UI.
+
+ Verify this results in the expected streams selected and all or no
+ fields selected for those streams.
+ """
+
+ # Select all available fields or select no fields from all testable streams
+ self._select_streams_and_fields(
+ conn_id=conn_id, catalogs=test_catalogs,
+ select_default_fields=select_default_fields,
+ select_pagination_fields=select_pagination_fields
+ )
+
+ catalogs = menagerie.get_catalogs(conn_id)
+
+ # Ensure our selection affects the catalog
+ expected_selected_streams = [tc.get('stream_name') for tc in test_catalogs]
+ expected_default_fields = self.expected_default_fields()
+ expected_pagination_fields = self.expected_pagination_fields()
+ for cat in catalogs:
+ catalog_entry = menagerie.get_annotated_schema(conn_id, cat['stream_id'])
+
+ # Verify all intended streams are selected
+ selected = catalog_entry['metadata'][0]['metadata'].get('selected')
+ print("Validating selection on {}: {}".format(cat['stream_name'], selected))
+ if cat['stream_name'] not in expected_selected_streams:
+ self.assertFalse(selected, msg="Stream selected, but not testable.")
+ continue # Skip remaining assertions if we aren't selecting this stream
+ self.assertTrue(selected, msg="Stream not selected.")
+
+ # collect field selection expecationas
+ expected_automatic_fields = self.expected_automatic_fields()[cat['stream_name']]
+ selected_default_fields = expected_default_fields[cat['stream_name']] if select_default_fields else set()
+ selected_pagination_fields = expected_pagination_fields[cat['stream_name']] if select_pagination_fields else set()
+
+ # Verify all intended fields within the stream are selected
+ expected_selected_fields = expected_automatic_fields | selected_default_fields | selected_pagination_fields
+ selected_fields = self._get_selected_fields_from_metadata(catalog_entry['metadata'])
+ for field in expected_selected_fields:
+ field_selected = field in selected_fields
+ print("\tValidating field selection on {}.{}: {}".format(cat['stream_name'], field, field_selected))
+ self.assertSetEqual(expected_selected_fields, selected_fields)
+
+ @staticmethod
+ def _get_selected_fields_from_metadata(metadata):
+ selected_fields = set()
+ for field in metadata:
+ is_field_metadata = len(field['breadcrumb']) > 1
+ inclusion_automatic_or_selected = (
+ field['metadata']['selected'] is True or \
+ field['metadata']['inclusion'] == 'automatic'
+ )
+ if is_field_metadata and inclusion_automatic_or_selected:
+ selected_fields.add(field['breadcrumb'][1])
+ return selected_fields
+
+ def _select_streams_and_fields(self, conn_id, catalogs, select_default_fields, select_pagination_fields):
+ """Select all streams and all fields within streams"""
+
+ for catalog in catalogs:
+
+ schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
+ metadata = schema_and_metadata['metadata']
+
+ properties = set(md['breadcrumb'][-1] for md in metadata
+ if len(md['breadcrumb']) > 0 and md['breadcrumb'][0] == 'properties')
+
+ # get a list of all properties so that none are selected
+ if select_default_fields:
+ non_selected_properties = properties.difference(
+ self.expected_default_fields()[catalog['stream_name']]
+ )
+ elif select_pagination_fields:
+ non_selected_properties = properties.difference(
+ self.expected_pagination_fields()[catalog['stream_name']]
+ )
+ else:
+ non_selected_properties = properties
+
+ connections.select_catalog_and_fields_via_metadata(
+ conn_id, catalog, schema_and_metadata, [], non_selected_properties)
+
+ @staticmethod
+ def parse_date(date_value):
+ """
+ Pass in string-formatted-datetime, parse the value, and return it as an unformatted datetime object.
+ """
+ date_formats = {
+ "%Y-%m-%dT%H:%M:%S.%fZ",
+ "%Y-%m-%dT%H:%M:%SZ",
+ "%Y-%m-%dT%H:%M:%S.%f+00:00",
+ "%Y-%m-%dT%H:%M:%S+00:00",
+ "%Y-%m-%d"
+ }
+ for date_format in date_formats:
+ try:
+ date_stripped = dt.strptime(date_value, date_format)
+ return date_stripped
+ except ValueError:
+ continue
+
+ raise NotImplementedError("Tests do not account for dates of this format: {}".format(date_value))
+
+ def timedelta_formatted(self, dtime, days=0):
+ try:
+ date_stripped = dt.strptime(dtime, self.START_DATE_FORMAT)
+ return_date = date_stripped + timedelta(days=days)
+
+ return dt.strftime(return_date, self.START_DATE_FORMAT)
+
+ except ValueError:
+ try:
+ date_stripped = dt.strptime(dtime, self.REPLICATION_KEY_FORMAT)
+ return_date = date_stripped + timedelta(days=days)
+
+ return dt.strftime(return_date, self.REPLICATION_KEY_FORMAT)
+
+ except ValueError:
+ return Exception("Datetime object is not of the format: {}".format(self.START_DATE_FORMAT))
+
+ ##########################################################################
+ ### Tap Specific Methods
+ ##########################################################################
+
+ # TODO exclusion rules
+
+ # TODO core objects vs reports
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
new file mode 100644
index 0000000..a3d637a
--- /dev/null
+++ b/tests/test_google_ads_discovery.py
@@ -0,0 +1,132 @@
+"""Test tap discovery mode and metadata."""
+import re
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class DiscoveryTest(GoogleAdsBase):
+ """Test tap discovery mode and metadata conforms to standards."""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_disco"
+
+ def test_name(self):
+ print("Discovery Test for tap-google-ads")
+
+ def test_run(self):
+ """
+ Testing that discovery creates the appropriate catalog with valid metadata.
+
+ • Verify number of actual streams discovered match expected
+ • Verify the stream names discovered were what we expect
+ • Verify stream names follow naming convention
+ streams should only have lowercase alphas and underscores
+ • verify there is only 1 top level breadcrumb
+ • verify replication key(s)
+ • verify primary key(s)
+ • verify that if there is a replication key we are doing INCREMENTAL otherwise FULL
+ • verify the actual replication matches our expected replication method
+ • verify that primary, replication and foreign keys
+ are given the inclusion of automatic.
+ • verify that all other fields have inclusion of available metadata.
+ """
+
+ conn_id = connections.ensure_connection(self)
+
+ streams_to_test = self.expected_streams()
+
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ print(found_catalogs)
+
+ # Verify stream names follow naming convention
+ # streams should only have lowercase alphas and underscores
+
+ found_catalog_names = {c['tap_stream_id'] for c in found_catalogs}
+ self.assertTrue(all([re.fullmatch(r"[a-z_]+", name) for name in found_catalog_names]),
+ msg="One or more streams don't follow standard naming")
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ # Verify the catalog is found for a given stream
+ catalog = next(iter([catalog for catalog in found_catalogs
+ if catalog["stream_name"] == stream]))
+ self.assertIsNotNone(catalog)
+
+ # collecting expected values
+ expected_primary_keys = self.expected_primary_keys()[stream]
+ #expected_foreign_keys = self.expected_foreign_keys()[stream]
+ expected_replication_keys = self.expected_replication_keys()[stream]
+ expected_automatic_fields = expected_primary_keys | expected_replication_keys
+ expected_replication_method = self.expected_replication_method()[stream]
+
+ # collecting actual values
+ schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
+ metadata = schema_and_metadata["metadata"]
+ stream_properties = [item for item in metadata if item.get("breadcrumb") == []]
+ actual_primary_keys = set(
+ stream_properties[0].get(
+ "metadata", {self.PRIMARY_KEYS: []}).get(self.PRIMARY_KEYS, [])
+ )
+ actual_foreign_keys = set(
+ stream_properties[0].get(
+ "metadata", {self.FOREIGN_KEYS: []}).get(self.FOREIGN_KEYS, [])
+ )
+ actual_replication_keys = set(
+ stream_properties[0].get(
+ "metadata", {self.REPLICATION_KEYS: []}).get(self.REPLICATION_KEYS, [])
+ )
+ actual_replication_method = stream_properties[0].get(
+ "metadata", {self.REPLICATION_METHOD: None}).get(self.REPLICATION_METHOD)
+ actual_automatic_fields = set(
+ item.get("breadcrumb", ["properties", None])[1] for item in metadata
+ if item.get("metadata").get("inclusion") == "automatic"
+ )
+ actual_fields = []
+ for md_entry in metadata:
+ if md_entry['breadcrumb'] != []:
+ actual_fields.append(md_entry['breadcrumb'][1])
+
+ ##########################################################################
+ ### metadata assertions
+ ##########################################################################
+
+ # verify there is only 1 top level breadcrumb in metadata
+ self.assertTrue(len(stream_properties) == 1,
+ msg="There is NOT only one top level breadcrumb for {}".format(stream) + \
+ "\nstream_properties | {}".format(stream_properties))
+
+ # verify there are no duplicate metadata entries
+ self.assertEqual(len(actual_fields), len(set(actual_fields)), msg = f"duplicates in the fields retrieved")
+ # verify primary key(s)
+ self.assertSetEqual(expected_primary_keys, actual_primary_keys, msg = f"expected primary keys is {expected_primary_keys} but actual primary keys is {actual_primary_keys}")
+
+ # verify replication method TODO
+
+ # verify replication key(s)
+ self.assertEqual(expected_replication_keys, actual_replication_keys, msg = f"expected replication key is {expected_replication_keys} but actual replication key is {actual_replication_keys}")
+
+ # verify replication key is present for any stream with replication method = INCREMENTAL
+ if actual_replication_method == 'INCREMENTAL':
+ self.assertEqual(expected_replication_keys, actual_replication_keys)
+ else:
+ self.assertEqual(actual_replication_keys,set())
+
+ # verify the stream is given the inclusion of available
+ self.assertEqual(catalog['metadata']['inclusion'],'available', msg=f"{stream} cannot be selected")
+
+ # verify the primary, replication keys are given the inclusions of automatic
+ self.assertSetEqual(expected_automatic_fields ,actual_automatic_fields)
+
+ # verify all other fields are given inclusion of available
+ self.assertTrue(
+ all({item.get("metadata").get("inclusion") == "available"
+ for item in metadata
+ if item.get("breadcrumb", []) != []
+ and item.get("breadcrumb", ["properties", None])[1]
+ not in actual_automatic_fields}),
+ msg="Not all non key properties are set to available in metadata")
From 3a8ac86fa6b6f669c4935e79747bbbee967e1f84 Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Tue, 11 Jan 2022 10:03:22 -0500
Subject: [PATCH 15/69] hardcode login and customer ids for now (#9)
* hardcode login and customer ids for now
* log better
Co-authored-by: kspeer
---
tests/base.py | 28 +++++++++++++++++++---------
tests/test_google_ads_discovery.py | 6 ++----
2 files changed, 21 insertions(+), 13 deletions(-)
diff --git a/tests/base.py b/tests/base.py
index 0b8953b..75c76e6 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -41,17 +41,27 @@ def get_type():
return "platform.google-ads"
def get_properties(self):
- #our test data is on the 9/15
- return {'start_date': '2018-04-12T00:00:00Z',
- # 'end_date': '2018-04-15T00:00:00Z',
- 'user_id': 'not used?',
- 'customer_ids': os.getenv('TAP_ADWORDS_CUSTOMER_IDS')}
+ return {
+ 'start_date': '2020-12-01T00:00:00Z',
+ 'user_id': 'not used?',
+ 'customer_ids': '5548074409,2728292456',
+ 'login_customer_ids': [
+ {
+ 'customerId': '5548074409',
+ 'loginCustomerId': '2728292456',
+ },
+ {
+ 'customerId': '2728292456',
+ 'loginCustomerId': '2728292456',
+ },
+ ],
+ }
def get_credentials(self):
- return {'developer_token': os.getenv('TAP_ADWORDS_DEVELOPER_TOKEN'),
- 'oauth_client_id': os.getenv('TAP_ADWORDS_OAUTH_CLIENT_ID'),
- 'oauth_client_secret': os.getenv('TAP_ADWORDS_OAUTH_CLIENT_SECRET'),
- 'refresh_token': os.getenv('TAP_ADWORDS_REFRESH_TOKEN')}
+ return {'developer_token': os.getenv('TAP_GOOGLE_ADS_DEVELOPER_TOKEN'),
+ 'oauth_client_id': os.getenv('TAP_GOOGLE_ADS_OAUTH_CLIENT_ID'),
+ 'oauth_client_secret': os.getenv('TAP_GOOGLE_ADS_OAUTH_CLIENT_SECRET'),
+ 'refresh_token': os.getenv('TAP_GOOGLE_ADS_REFRESH_TOKEN')}
def expected_metadata(self):
"""The expected streams and metadata about the streams"""
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index a3d637a..88a8f13 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -13,9 +13,6 @@ class DiscoveryTest(GoogleAdsBase):
def name():
return "tt_google_ads_disco"
- def test_name(self):
- print("Discovery Test for tap-google-ads")
-
def test_run(self):
"""
Testing that discovery creates the appropriate catalog with valid metadata.
@@ -33,6 +30,7 @@ def test_run(self):
are given the inclusion of automatic.
• verify that all other fields have inclusion of available metadata.
"""
+ print("Discovery Test for tap-google-ads")
conn_id = connections.ensure_connection(self)
@@ -40,7 +38,7 @@ def test_run(self):
found_catalogs = self.run_and_verify_check_mode(conn_id)
- print(found_catalogs)
+ print(f"found_catalogs: {found_catalogs}")
# Verify stream names follow naming convention
# streams should only have lowercase alphas and underscores
From e22ecc14327bf200203420719ca0549e5b638d98 Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Thu, 20 Jan 2022 13:33:32 -0500
Subject: [PATCH 16/69] Discover reports (#8)
* Begin report discovery work
* Add report discovery
* make pylint happy and black formatting
* Add login_customer_id to test config
* fix shape of login_customer_id in test config
* add report streams to test and fix missing metadata for report streams
* Update test to use Adwords report names, update primary and replication keys
* Update discovery code to discover reports
* Add workaround for all caps report names
* Rename BaseReport to BaseStream, Initialize core streams, Fix missing
field in AD_PERFORMANCE_REPORT_FIELDS
* Rename functions, add discovery for core streams
* Enable tests for core streams, update names to be capitalized
* Update report streams to be capitalized snake case
* Update expectation to allow `"inclusion": "unsupported"`
* Sync core streams (#10)
* Remove report streams
* Move report definitions to new file, change name of
PlaceholderFeedItemPerformanceReport to PlaceholderFeedItemReport, changed
Placeholder_Feed_Item_Report from a BaseStream to
PlaceholderFeedItemReport, add sync func to BaseStream
* Create resource_schema in main to pass to discover and sync, call
BaseStream.sync for core streams, remove unused functions, fix whitespace
* Format using black
* Make pylint happy
* Stop passing params that exist on `stream`
* Rename `stream` to `catalog_entry`
* Make pylint happier
* Make pylint happy, format with black
* Organize reports
* Add unit test for flatten
* Add unit tests for get_segments and get_attributes
* Add more core streams
* Make stream names lowercase
* Remove attributed resources from core streams, Remove resource name,
transform field names, run black
* Fix attribute field exclusion bug
* Display streams as plural resource names
* Transform fields to google_ads field names before query, transform back to schema def
* Add tests for field name transform
Co-authored-by: Andy Lu
Co-authored-by: Bryant Gray
Co-authored-by: kspeer
---
tap_google_ads/__init__.py | 420 +++++++++++++++++++---
tap_google_ads/report_definitions.py | 119 +++++++
tap_google_ads/reports.py | 445 ++++++++++++++++++++++++
tests/base.py | 171 +++++----
tests/test_google_ads_discovery.py | 4 +-
tests/unittests/test_resource_schema.py | 101 ++++++
tests/unittests/test_utils.py | 44 +++
7 files changed, 1180 insertions(+), 124 deletions(-)
create mode 100644 tap_google_ads/report_definitions.py
create mode 100644 tap_google_ads/reports.py
create mode 100644 tests/unittests/test_resource_schema.py
create mode 100644 tests/unittests/test_utils.py
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 2275781..4c4d52c 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -1,21 +1,23 @@
#!/usr/bin/env python3
import json
import os
+import re
import sys
import singer
from singer import utils
-from singer import metrics
from singer import bookmarks
from singer import metadata
-from singer import (transform,
- UNIX_MILLISECONDS_INTEGER_DATETIME_PARSING,
- Transformer)
+from singer import transform, UNIX_MILLISECONDS_INTEGER_DATETIME_PARSING, Transformer
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
from google.protobuf.json_format import MessageToJson
+from tap_google_ads.reports import initialize_core_streams
+from tap_google_ads.reports import initialize_reports
+
+API_VERSION = "v9"
LOGGER = singer.get_logger()
@@ -28,73 +30,397 @@
"developer_token",
]
-CORE_ENDPOINT_MAPPINGS = {"campaigns": {'primary_keys': ["id"],
- 'service_name': 'CampaignService'},
- "ad_groups": {'primary_keys': ["id"],
- 'service_name': 'AdGroupService'},
- "ads": {'primary_keys': ["id"],
- 'service_name': 'AdGroupAdService'},
- "accounts": {'primary_keys': ["id"],
- 'service_name': 'ManagedCustomerService'}}
+CORE_ENDPOINT_MAPPINGS = {
+ "campaign": {"primary_keys": ["id"], "stream_name": "campaigns"},
+ "ad_group": {"primary_keys": ["id"], "stream_name": "ad_groups"},
+ "ad_group_ad": {"primary_keys": ["id"], "stream_name": "ads"},
+ "customer": {"primary_keys": ["id"], "stream_name": "accounts"},
+}
+
+REPORTS = [
+ "accessible_bidding_strategy",
+ "ad_group",
+ "ad_group_ad",
+ "ad_group_audience_view",
+ "age_range_view",
+ "bidding_strategy",
+ "call_view",
+ "campaign",
+ "campaign_audience_view",
+ "campaign_budget",
+ "click_view",
+ "customer",
+ "display_keyword_view",
+ "dynamic_search_ads_search_term_view",
+ "expanded_landing_page_view",
+ "feed_item",
+ "feed_item_target",
+ "feed_placeholder_view",
+ "gender_view",
+ "geographic_view",
+ "keyword_view",
+ "landing_page_view",
+ "managed_placement_view",
+ "search_term_view",
+ "shopping_performance_view",
+ "topic_view",
+ "user_location_view",
+ "video",
+]
+
+CATEGORY_MAP = {
+ 0: "UNSPECIFIED",
+ 1: "UNKNOWN",
+ 2: "RESOURCE",
+ 3: "ATTRIBUTE",
+ 5: "SEGMENT",
+ 6: "METRIC",
+}
+
+
+def get_attributes(api_objects, resource):
+ resource_attributes = []
+
+ if CATEGORY_MAP[resource.category] != "RESOURCE":
+ # Attributes, segments, and metrics do not have attributes
+ return resource_attributes
+
+ attributed_resources = set(resource.attribute_resources)
+ for field in api_objects:
+ root_object_name = field.name.split(".")[0]
+ does_field_exist_on_resource = (
+ root_object_name == resource.name
+ or root_object_name in attributed_resources
+ )
+ is_field_an_attribute = CATEGORY_MAP[field.category] == "ATTRIBUTE"
+ if is_field_an_attribute and does_field_exist_on_resource:
+ resource_attributes.append(field.name)
+ return resource_attributes
+
+
+def get_segments(resource_schema, resource):
+ resource_segments = []
+
+ if resource["category"] != "RESOURCE":
+ # Attributes, segments, and metrics do not have attributes
+ return resource_segments
+
+ segments = resource["segments"]
+ for segment in segments:
+ if segment.startswith("segments."):
+ resource_segments.append(segment)
+ else:
+ segment_schema = resource_schema[segment]
+ segment_attributes = [
+ attribute
+ for attribute in segment_schema["attributes"]
+ if attribute.startswith(f"{segment}.")
+ ]
+ resource_segments.extend(segment_attributes)
+ return resource_segments
+
+
+def create_resource_schema(config):
+ client = GoogleAdsClient.load_from_dict(get_client_config(config))
+ gaf_service = client.get_service("GoogleAdsFieldService")
+
+ query = "SELECT name, category, data_type, selectable, filterable, sortable, selectable_with, metrics, segments, is_repeated, type_url, enum_values, attribute_resources"
+
+ api_objects = gaf_service.search_google_ads_fields(query=query)
+
+ # These are the data types returned from google. They are mapped to json schema. UNSPECIFIED and UNKNOWN have never been returned.
+ # 0: "UNSPECIFIED", 1: "UNKNOWN", 2: "BOOLEAN", 3: "DATE", 4: "DOUBLE", 5: "ENUM", 6: "FLOAT", 7: "INT32", 8: "INT64", 9: "MESSAGE", 10: "RESOURCE_NAME", 11: "STRING", 12: "UINT64"
+ data_type_map = {
+ 0: {"type": ["null", "string"]},
+ 1: {"type": ["null", "string"]},
+ 2: {"type": ["null", "boolean"]},
+ 3: {"type": ["null", "string"]},
+ 4: {"type": ["null", "string"], "format": "singer.decimal"},
+ 5: {"type": ["null", "string"]},
+ 6: {"type": ["null", "string"], "format": "singer.decimal"},
+ 7: {"type": ["null", "integer"]},
+ 8: {"type": ["null", "integer"]},
+ 9: {"type": ["null", "object", "string"], "properties": {}},
+ 10: {"type": ["null", "object", "string"], "properties": {}},
+ 11: {"type": ["null", "string"]},
+ 12: {"type": ["null", "integer"]},
+ }
+
+ resource_schema = {}
+
+ for resource in api_objects:
+ attributes = get_attributes(api_objects, resource)
+
+ resource_metadata = {
+ "name": resource.name,
+ "category": CATEGORY_MAP[resource.category],
+ "json_schema": data_type_map[resource.data_type],
+ "selectable": resource.selectable,
+ "filterable": resource.filterable,
+ "sortable": resource.sortable,
+ "selectable_with": set(resource.selectable_with),
+ "metrics": list(resource.metrics),
+ "segments": list(resource.segments),
+ "attributes": attributes,
+ }
+
+ resource_schema[resource.name] = resource_metadata
+
+ for resource in resource_schema.values():
+ updated_segments = get_segments(resource_schema, resource)
+ resource["segments"] = updated_segments
+
+ for report in REPORTS:
+ report_object = resource_schema[report]
+ fields = {}
+ attributes = report_object["attributes"]
+ metrics = report_object["metrics"]
+ segments = report_object["segments"]
+ for field in attributes + metrics + segments:
+ field_schema = dict(resource_schema[field])
+
+ if field_schema["name"] in segments:
+ field_schema["category"] = "SEGMENT"
+
+ fields[field_schema["name"]] = {
+ "field_details": field_schema,
+ "incompatible_fields": [],
+ }
+
+ metrics_and_segments = set(metrics + segments)
+ for field_name, field in fields.items():
+ if field["field_details"]["category"] == "ATTRIBUTE":
+ continue
+ for compared_field in metrics_and_segments:
+
+ if not (
+ field_name.startswith("segments.")
+ or field_name.startswith("metrics.")
+ ):
+ field_root_resource = field_name.split(".")[0]
+ else:
+ field_root_resource = None
+
+ if (field_name != compared_field) and (
+ compared_field.startswith("metrics.")
+ or compared_field.startswith("segments.")
+ ):
+ field_to_check = field_root_resource or field_name
+ if (
+ field_to_check
+ not in resource_schema[compared_field]["selectable_with"]
+ ):
+ field["incompatible_fields"].append(compared_field)
+
+ report_object["fields"] = fields
+ return resource_schema
+
+
+def canonicalize_name(name):
+ """Remove all dot and underscores and camel case the name."""
+ tokens = re.split("\\.|_", name)
+
+ first_word = [tokens[0]]
+ other_words = [word.capitalize() for word in tokens[1:]]
+
+ return "".join(first_word + other_words)
+
-def create_field_metadata(stream, schema):
- primary_key = CORE_ENDPOINT_MAPPINGS[stream]['primary_keys']
+def do_discover_core_streams(resource_schema):
+ adwords_to_google_ads = initialize_core_streams(resource_schema)
+ catalog = []
+ for stream_name, stream in adwords_to_google_ads.items():
+ resource_object = resource_schema[stream.google_ads_resources_name[0]]
+ fields = resource_object["fields"]
+ report_schema = {}
+ report_metadata = {
+ tuple(): {
+ "inclusion": "available",
+ "table-key-properties": stream.primary_keys,
+ }
+ }
+
+ for field, props in fields.items():
+ resource_matches = field.startswith(resource_object["name"] + ".")
+ is_id_field = field.endswith(".id")
+
+ if props["field_details"]["category"] == "ATTRIBUTE" and (
+ resource_matches or is_id_field
+ ):
+ if resource_matches:
+ field = ".".join(field.split(".")[1:])
+ elif is_id_field:
+ field = field.replace(".", "_")
+
+ the_schema = props["field_details"]["json_schema"]
+ report_schema[field] = the_schema
+ report_metadata[("properties", field)] = {
+ "fieldExclusions": props["incompatible_fields"],
+ "behavior": props["field_details"]["category"],
+ }
+ if field in stream.primary_keys:
+ inclusion = "automatic"
+ elif props["field_details"]["selectable"]:
+ inclusion = "available"
+ else:
+ inclusion = "unsupported"
+ report_metadata[("properties", field)]["inclusion"] = inclusion
+
+ catalog_entry = {
+ "tap_stream_id": stream.google_ads_resources_name[0],
+ "stream": stream_name,
+ "schema": {
+ "type": ["null", "object"],
+ "properties": report_schema,
+ },
+ "metadata": singer.metadata.to_list(report_metadata),
+ }
+ catalog.append(catalog_entry)
+ return catalog
+
+
+def create_field_metadata(primary_key, schema):
mdata = {}
- mdata = metadata.write(mdata, (), 'inclusion', 'available')
- mdata = metadata.write(mdata, (), 'table-key-properties', primary_key)
+ mdata = metadata.write(mdata, (), "inclusion", "available")
+ mdata = metadata.write(mdata, (), "table-key-properties", primary_key)
- for field in schema['properties']:
- breadcrumb = ('properties', str(field))
- mdata = metadata.write(mdata, breadcrumb, 'inclusion', 'available')
+ for field in schema["properties"]:
+ breadcrumb = ("properties", str(field))
+ mdata = metadata.write(mdata, breadcrumb, "inclusion", "available")
- mdata = metadata.write(mdata, ('properties', primary_key[0]), 'inclusion', 'automatic')
+ mdata = metadata.write(
+ mdata, ("properties", primary_key[0]), "inclusion", "automatic"
+ )
mdata = metadata.to_list(mdata)
return mdata
-def get_abs_path(path):
- return os.path.join(os.path.dirname(os.path.realpath(__file__)), path)
-def load_schema(entity):
- return utils.load_json(get_abs_path(f"schemas/{entity}.json"))
+def create_sdk_client(config, login_customer_id=None):
+ CONFIG = {
+ "use_proto_plus": False,
+ "developer_token": config["developer_token"],
+ "client_id": config["oauth_client_id"],
+ "client_secret": config["oauth_client_secret"],
+ "access_token": config["access_token"],
+ "refresh_token": config["refresh_token"],
+ }
-def load_metadata(entity):
- return utils.load_json(get_abs_path(f"metadata/{entity}.json"))
+ if login_customer_id:
+ CONFIG["login_customer_id"] = login_customer_id
-def do_discover_core_endpoints():
- streams = []
- LOGGER.info("Starting core discovery")
- for stream_name in CORE_ENDPOINT_MAPPINGS:
- LOGGER.info('Loading schema for %s', stream_name)
- schema = load_schema(stream_name)
- md = create_field_metadata(stream_name, schema)
- streams.append({'stream': stream_name,
- 'tap_stream_id': stream_name,
- 'schema': schema,
- 'metadata': md})
- return streams
+ sdk_client = GoogleAdsClient.load_from_dict(CONFIG)
+ return sdk_client
+
+
+def do_sync(config, catalog, resource_schema):
+ customers = json.loads(config["login_customer_ids"])
+
+ selected_streams = [
+ stream
+ for stream in catalog["streams"]
+ if singer.metadata.to_map(stream["metadata"])[()].get("selected")
+ ]
+
+ core_streams = initialize_core_streams(resource_schema)
+
+ for customer in customers:
+ sdk_client = create_sdk_client(config, customer["loginCustomerId"])
+ for catalog_entry in selected_streams:
+ stream_name = catalog_entry["stream"]
+ if stream_name in core_streams:
+ stream_obj = core_streams[stream_name]
+
+ mdata_map = singer.metadata.to_map(catalog_entry["metadata"])
+
+ primary_key = (
+ mdata_map[()].get("metadata", {}).get("table-key-properties", [])
+ )
+ singer.messages.write_schema(
+ stream_name, catalog_entry["schema"], primary_key
+ )
+ stream_obj.sync(sdk_client, customer, catalog_entry)
-def do_discover():
- #sdk_client = create_sdk_client()
- core_streams = do_discover_core_endpoints()
- # report_streams = do_discover_reports(sdk_client)
+
+def do_discover(resource_schema):
+ core_streams = do_discover_core_streams(resource_schema)
+ # report_streams = do_discover_reports(resource_schema)
streams = []
streams.extend(core_streams)
# streams.extend(report_streams)
json.dump({"streams": streams}, sys.stdout, indent=2)
-def create_sdk_client():
- CONFIG = {}
- sdk_client = GoogleAdsClient.load_from_dict(CONFIG)
- return sdk_client
+
+def do_discover_reports(resource_schema):
+ ADWORDS_TO_GOOGLE_ADS = initialize_reports(resource_schema)
+
+ streams = []
+ for adwords_report_name, report in ADWORDS_TO_GOOGLE_ADS.items():
+ report_mdata = {tuple(): {"inclusion": "available"}}
+ try:
+ for report_field in report.fields:
+ # field = resource_schema[report_field]
+ report_mdata[("properties", report_field)] = {
+ # "fieldExclusions": report.field_exclusions.get(report_field, []),
+ # "behavior": report.behavior.get(report_field, "ATTRIBUTE"),
+ "fieldExclusions": report.field_exclusions[report_field],
+ "behavior": report.behavior[report_field],
+ }
+
+ if report.behavior[report_field]:
+ inclusion = "available"
+ else:
+ inclusion = "unsupported"
+ report_mdata[("properties", report_field)]["inclusion"] = inclusion
+ except Exception as err:
+ print(f"Error in {adwords_report_name}")
+ raise err
+
+ catalog_entry = {
+ "tap_stream_id": adwords_report_name,
+ "stream": adwords_report_name,
+ "schema": {
+ "type": ["null", "object"],
+ "is_report": True,
+ "properties": report.schema,
+ },
+ "metadata": singer.metadata.to_list(report_mdata),
+ }
+ streams.append(catalog_entry)
+
+ return streams
+
+
+def get_client_config(config, login_customer_id=None):
+ client_config = {
+ "use_proto_plus": False,
+ "developer_token": config["developer_token"],
+ "client_id": config["oauth_client_id"],
+ "client_secret": config["oauth_client_secret"],
+ "refresh_token": config["refresh_token"],
+ # "access_token": config["access_token"],
+ }
+
+ if login_customer_id:
+ client_config["login_customer_id"] = login_customer_id
+
+ return client_config
+
def main():
args = utils.parse_args(REQUIRED_CONFIG_KEYS)
+ resource_schema = create_resource_schema(args.config)
if args.discover:
- do_discover()
+ do_discover(resource_schema)
LOGGER.info("Discovery complete")
+ elif args.catalog:
+ do_sync(args.config, args.catalog.to_dict(), resource_schema)
+ LOGGER.info("Sync Completed")
+ else:
+ LOGGER.info("No properties were selected")
+
if __name__ == "__main__":
main()
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
new file mode 100644
index 0000000..c940c39
--- /dev/null
+++ b/tap_google_ads/report_definitions.py
@@ -0,0 +1,119 @@
+ACCOUNT_FIELDS = []
+AD_GROUP_FIELDS = []
+AD_GROUP_AD_FIELDS = []
+CAMPAIGN_FIELDS = []
+BIDDING_STRATEGY_FIELDS = []
+ACCESSIBLE_BIDDING_STRATEGY_FIELDS = []
+CAMPAIGN_BUDGET_FIELDS = []
+ACCOUNT_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'customer.manager', 'segments.click_type', 'metrics.clicks', 'metrics.content_budget_lost_impression_share', 'metrics.content_impression_share', 'metrics.content_rank_lost_impression_share', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'segments.hour', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'metrics.invalid_click_rate', 'metrics.invalid_clicks', 'customer.auto_tagging_enabled', 'customer.test_account', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.search_budget_lost_impression_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'metrics.search_rank_lost_impression_share', 'segments.slot', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+ADGROUP_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'ad_group.type', 'segments.ad_network_type', 'ad_group.ad_rotation_mode', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy', 'campaign.bidding_strategy_type', 'metrics.bounce_rate', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'ad_group.display_custom_bid_dimension', 'metrics.content_impression_share', 'metrics.content_rank_lost_impression_share', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'ad_group.cpc_bid_micros', 'ad_group.cpm_bid_micros', 'ad_group.cpv_bid_micros', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'ad_group.effective_target_roas', 'ad_group.effective_target_roas_source', 'metrics.engagement_rate', 'metrics.engagements', 'campaign.manual_cpc.enhanced_cpc_enabled', 'campaign.percent_cpc.enhanced_cpc_enabled', 'segments.external_conversion_source', 'customer.id', 'ad_group.final_url_suffix', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'segments.hour', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'label.resource_name', 'segments.month', 'segments.month_of_year', 'metrics.phone_impressions', 'metrics.phone_calls', 'metrics.phone_through_rate', 'metrics.percent_new_visitors', 'segments.quarter', 'metrics.relative_ctr', 'metrics.search_absolute_top_impression_share', 'metrics.search_budget_lost_absolute_top_impression_share', 'metrics.search_budget_lost_top_impression_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'metrics.search_rank_lost_absolute_top_impression_share', 'metrics.search_rank_lost_impression_share', 'metrics.search_rank_lost_top_impression_share', 'metrics.search_top_impression_share', 'segments.slot', 'ad_group.effective_target_cpa_micros', 'ad_group.effective_target_cpa_source', 'metrics.top_impression_percentage', 'ad_group.tracking_url_template', 'ad_group.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+AD_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'ad_group_ad.ad.legacy_responsive_display_ad.accent_color', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'ad_group_ad.ad_strength', 'ad_group_ad.ad.type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'ad_group_ad.ad.legacy_responsive_display_ad.allow_flexible_color', 'ad_group_ad.ad.added_by_google_ads', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'ad_group.base_ad_group', 'campaign.base_campaign', 'metrics.bounce_rate', 'ad_group_ad.ad.legacy_responsive_display_ad.business_name', 'ad_group_ad.ad.call_ad.phone_number', 'ad_group_ad.ad.legacy_responsive_display_ad.call_to_action_text', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'ad_group_ad.policy_summary.approval_status', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'ad_group_ad.ad.final_mobile_urls', 'ad_group_ad.ad.final_urls', 'ad_group_ad.ad.tracking_url_template', 'ad_group_ad.ad.url_custom_parameters', 'segments.keyword.ad_group_criterion', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week',
+ 'ad_group_ad.ad.legacy_responsive_display_ad.description', 'ad_group_ad.ad.expanded_text_ad.description',
+ 'ad_group_ad.ad.text_ad.description1', 'ad_group_ad.ad.call_ad.description1',
+ 'ad_group_ad.ad.text_ad.description2', 'ad_group_ad.ad.call_ad.description2',
+ 'ad_group_criterion.negative',
+ 'segments.device', 'ad_group_ad.ad.device_preference', 'ad_group_ad.ad.display_url', 'metrics.engagement_rate', 'metrics.engagements', 'ad_group_ad.ad.legacy_responsive_display_ad.logo_image', 'ad_group_ad.ad.legacy_responsive_display_ad.square_logo_image', 'ad_group_ad.ad.legacy_responsive_display_ad.marketing_image', 'ad_group_ad.ad.legacy_responsive_display_ad.square_marketing_image', 'ad_group_ad.ad.expanded_dynamic_search_ad.description', 'ad_group_ad.ad.expanded_text_ad.description2', 'ad_group_ad.ad.expanded_text_ad.headline_part3', 'segments.external_conversion_source', 'customer.id', 'ad_group_ad.ad.legacy_responsive_display_ad.format_setting', 'ad_group_ad.ad.gmail_ad.header_image', 'ad_group_ad.ad.gmail_ad.teaser.logo_image', 'ad_group_ad.ad.gmail_ad.marketing_image', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_ad.ad.gmail_ad.teaser.business_name', 'ad_group_ad.ad.gmail_ad.teaser.description', 'ad_group_ad.ad.gmail_ad.teaser.headline', 'ad_group_ad.ad.text_ad.headline', 'ad_group_ad.ad.expanded_text_ad.headline_part1', 'ad_group_ad.ad.expanded_text_ad.headline_part2', 'ad_group_ad.ad.id', 'ad_group_ad.ad.image_ad.image_url', 'ad_group_ad.ad.image_ad.pixel_height', 'ad_group_ad.ad.image_ad.pixel_width', 'ad_group_ad.ad.image_ad.mime_type', 'ad_group_ad.ad.image_ad.name', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'label.resource_name', 'label.name', 'ad_group_ad.ad.legacy_responsive_display_ad.long_headline', 'ad_group_ad.ad.legacy_responsive_display_ad.main_color', 'ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text', 'ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text_color', 'ad_group_ad.ad.gmail_ad.marketing_image_headline', 'ad_group_ad.ad.gmail_ad.marketing_image_description', 'segments.month', 'segments.month_of_year', 'ad_group_ad.ad.responsive_display_ad.accent_color', 'ad_group_ad.ad.responsive_display_ad.allow_flexible_color', 'ad_group_ad.ad.responsive_display_ad.business_name', 'ad_group_ad.ad.responsive_display_ad.call_to_action_text', 'ad_group_ad.ad.responsive_display_ad.descriptions', 'ad_group_ad.ad.responsive_display_ad.price_prefix', 'ad_group_ad.ad.responsive_display_ad.promo_text', 'ad_group_ad.ad.responsive_display_ad.format_setting', 'ad_group_ad.ad.responsive_display_ad.headlines', 'ad_group_ad.ad.responsive_display_ad.logo_images', 'ad_group_ad.ad.responsive_display_ad.square_logo_images', 'ad_group_ad.ad.responsive_display_ad.long_headline', 'ad_group_ad.ad.responsive_display_ad.main_color', 'ad_group_ad.ad.responsive_display_ad.marketing_images', 'ad_group_ad.ad.responsive_display_ad.square_marketing_images', 'ad_group_ad.ad.responsive_display_ad.youtube_videos', 'ad_group_ad.ad.expanded_text_ad.path1', 'ad_group_ad.ad.expanded_text_ad.path2', 'metrics.percent_new_visitors',
+ 'ad_group_ad.policy_summary.policy_topic_entries',
+ #'ad_group_ad.policy_summary.review_state',
+ 'ad_group_ad.policy_summary.review_status',
+ 'ad_group_ad.policy_summary.approval_status',
+ 'ad_group_ad.ad.legacy_responsive_display_ad.price_prefix', 'ad_group_ad.ad.legacy_responsive_display_ad.promo_text', 'segments.quarter', 'ad_group_ad.ad.responsive_search_ad.descriptions', 'ad_group_ad.ad.responsive_search_ad.headlines', 'ad_group_ad.ad.responsive_search_ad.path1', 'ad_group_ad.ad.responsive_search_ad.path2', 'ad_group_ad.ad.legacy_responsive_display_ad.short_headline', 'segments.slot', 'ad_group_ad.status', 'ad_group_ad.ad.system_managed_resource_source', 'metrics.top_impression_percentage', 'ad_group_ad.ad.app_ad.descriptions', 'ad_group_ad.ad.app_ad.headlines', 'ad_group_ad.ad.app_ad.html5_media_bundles', 'ad_group_ad.ad.app_ad.images', 'ad_group_ad.ad.app_ad.mandatory_ad_text', 'ad_group_ad.ad.app_ad.youtube_videos', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+AGE_RANGE_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy', 'bidding_strategy.name', 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.age_range.type', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+AUDIENCE_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy',
+ 'campaign_criterion.bid_modifier',
+ 'ad_group_criterion.bid_modifier',
+ 'bidding_strategy.name',
+ #'campaign.bidding_strategy.type',
+ 'campaign.bidding_strategy_type',
+ 'ad_group.campaign', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion',
+ #'This should be campaign/ad group criterion depending on the view.',
+ 'ad_group_criterion.effective_cpc_bid_micros',
+ 'ad_group_criterion.effective_cpc_bid_source',
+ 'ad_group_criterion.effective_cpm_bid_micros',
+ 'ad_group_criterion.effective_cpm_bid_source',
+ #'This should be campaign/ad group bid modifier depending on the view.',
+ 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'segments.slot', 'ad_group_criterion.status', 'ad_group.tracking_url_template', 'ad_group.url_custom_parameters', 'user_list.name', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+AUTOMATIC_PLACEMENTS_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'Use group_placement_view.placement_type or group_placement_view.target_url.', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'Returns domain name for websites and YouTube channel name for YouTube channels', 'group_placement_view.target_url', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+BID_GOAL_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'bidding_strategy.campaign_count', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'segments.hour', 'bidding_strategy.id', 'metrics.impressions', 'segments.month', 'segments.month_of_year', 'bidding_strategy.name must be selected withy the resources bidding_strategy or campaign.', 'bidding_strategy.non_removed_campaign_count', 'segments.quarter', 'bidding_strategy.status', 'bidding_strategy.target_cpa.target_cpa_micros', 'bidding_strategy.target_cpa.cpc_bid_ceiling_micros', 'bidding_strategy.target_cpa.cpc_bid_floor_micros', 'bidding_strategy.target_roas.target_roas', 'bidding_strategy.target_roas.cpc_bid_ceiling_micros', 'bidding_strategy.target_roas.cpc_bid_floor_micros', 'bidding_strategy.target_spend.cpc_bid_ceiling_micros', 'bidding_strategy.target_spend.target_spend_micros', 'bidding_strategy.type', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+BUDGET_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'campaign_budget.amount_micros', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'segments.budget_campaign_association_status.status', 'campaign_budget.id', 'campaign_budget.name', 'campaign_budget.reference_count', 'campaign_budget.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'campaign_budget.delivery_method', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'campaign_budget.has_recommended_budget', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'campaign_budget.explicitly_shared', 'campaign_budget.period', 'campaign_budget.recommended_budget_amount_micros', 'campaign_budget.recommended_budget_estimated_change_weekly_clicks', 'campaign_budget.recommended_budget_estimated_change_weekly_cost_micros', 'campaign_budget.recommended_budget_estimated_change_weekly_interactions', 'campaign_budget.recommended_budget_estimated_change_weekly_views', 'campaign_budget.total_amount_micros', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions']
+CALL_METRICS_CALL_DETAILS_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'call_view.call_duration_seconds', 'call_view.end_call_date_time', 'call_view.start_call_date_time', 'call_view.call_status', 'call_view.call_tracking_display_location', 'call_view.type', 'call_view.caller_area_code', 'call_view.caller_country_code', 'campaign.id', 'campaign.name', 'campaign.status', 'customer.descriptive_name',
+ #'segments.date',
+ #'segments.day_of_week',
+ 'customer.id',
+ #'segments.hour',
+ #'segments.month',
+ #'segments.month_of_year',
+ #'segments.quarter',
+ #'segments.week',
+ #'segments.year'
+]
+CAMPAIGN_AD_SCHEDULE_TARGET_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign_criterion.bid_modifier', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'campaign_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+CAMPAIGN_CRITERIA_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'campaign.base_campaign', 'campaign.id', 'campaign.name', 'campaign.status', 'campaign_criterion.keyword.text OR campaign_criterion.placement.url, etc.', 'campaign_criterion.type', 'customer.descriptive_name', 'customer.id', 'campaign_criterion.criterion_id', 'campaign_criterion.negative']
+CAMPAIGN_LOCATION_TARGET_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign_criterion.bid_modifier', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'campaign_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'campaign_criterion.negative', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+CAMPAIGN_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'segments.ad_network_type', 'campaign.advertising_channel_sub_type', 'campaign.advertising_channel_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'campaign_budget.amount_micros', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'campaign.base_campaign', 'campaign.bidding_strategy', 'bidding_strategy.name', 'campaign.bidding_strategy_type', 'metrics.bounce_rate', 'campaign.campaign_budget',
+ # "Not available with 'FROM campaign'. However, you can retrieve device type bid modifiers via 'FROM campaign_criterion' queries.",
+ 'campaign_criterion.device.type',
+ 'campaign.id',
+ #"Not available with 'FROM campaign'. However, you can retrieve device type bid modifiers via 'FROM campaign_criterion' queries.",
+ 'campaign.name', 'campaign.status',
+ #"Not available with 'FROM campaign'. However, you can retrieve device type bid modifiers via 'FROM campaign_criterion' queries.",
+ 'campaign.experiment_type', 'segments.click_type', 'metrics.clicks', 'metrics.content_budget_lost_impression_share', 'metrics.content_impression_share', 'metrics.content_rank_lost_impression_share', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_attribution_event_type', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'campaign.end_date', 'metrics.engagement_rate', 'metrics.engagements', 'campaign.manual_cpc.enhanced_cpc_enabled', 'campaign.percent_cpc.enhanced_cpc_enabled', 'segments.external_conversion_source', 'customer.id', 'campaign.final_url_suffix', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'campaign_budget.has_recommended_budget', 'segments.hour', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'metrics.invalid_click_rate', 'metrics.invalid_clicks', 'campaign_budget.explicitly_shared',
+ #'Select label.resource_name from the resource campaign_label',
+ #'Select label.resource_name from the resource campaign_label',
+ 'label.resource_name',
+ 'campaign.maximize_conversion_value.target_roas', 'segments.month', 'segments.month_of_year', 'metrics.phone_impressions', 'metrics.phone_calls', 'metrics.phone_through_rate', 'metrics.percent_new_visitors', 'campaign_budget.period', 'segments.quarter', 'campaign_budget.recommended_budget_amount_micros', 'metrics.relative_ctr', 'metrics.search_absolute_top_impression_share', 'metrics.search_budget_lost_absolute_top_impression_share', 'metrics.search_budget_lost_impression_share', 'metrics.search_budget_lost_top_impression_share', 'metrics.search_click_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'metrics.search_rank_lost_absolute_top_impression_share', 'metrics.search_rank_lost_impression_share', 'metrics.search_rank_lost_top_impression_share', 'metrics.search_top_impression_share', 'campaign.serving_status', 'segments.slot', 'campaign.start_date', 'metrics.top_impression_percentage', 'campaign_budget.total_amount_micros', 'campaign.tracking_url_template', 'campaign.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+CAMPAIGN_SHARED_SET_REPORT_FIELDS = ['customer.descriptive_name', 'campaign.id', 'campaign.name', 'campaign.status', 'customer.id', 'shared_set.id', 'shared_set.name', 'shared_set.type', 'campaign_shared_set.status']
+CLICK_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'click_view.area_of_interest.city', 'click_view.area_of_interest.country', 'click_view.area_of_interest.metro', 'click_view.area_of_interest.most_specific', 'click_view.area_of_interest.region', 'campaign.id', 'click_view.campaign_location_target', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'click_view.ad_group_ad', 'segments.date', 'segments.device', 'customer.id', 'click_view.gclid', 'click_view.location_of_presence.city', 'click_view.location_of_presence.country', 'click_view.location_of_presence.metro', 'click_view.location_of_presence.most_specific', 'click_view.location_of_presence.region', 'segments.month_of_year', 'click_view.page_number', 'segments.slot', 'click_view.user_list']
+DISPLAY_KEYWORD_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy',
+ #'bidding_strategy.name must be selected with the resources bidding_strategy and campaign.',
+ 'bidding_strategy.name',
+ 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.effective_cpv_bid_micros', 'ad_group_criterion.effective_cpv_bid_source', 'ad_group_criterion.keyword.text', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+DISPLAY_TOPICS_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy',
+ #'bidding_strategy.name must be selected with the resources bidding_strategy and campaign.',
+ 'bidding_strategy.name',
+ 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.topic.path', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'ad_group_criterion.topic.topic_constant', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+GENDER_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy', 'bidding_strategy.name', 'bidding_strategy.type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.gender.type', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+GEO_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.geo_target_city', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion',
+ #'Use geographic_view.country_criterion_id or user_location_view.country_criterion_id depending upon which view you want',
+ 'geographic_view.country_criterion_id',
+ 'user_location_view.country_criterion_id',
+ 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'user_location_view.targeting_location', 'geographic_view.location_type', 'segments.geo_target_metro', 'segments.month', 'segments.month_of_year', 'segments.geo_target_most_specific_location', 'segments.quarter', 'segments.geo_target_region', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+KEYWORDLESS_QUERY_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'segments.webpage', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'dynamic_search_ads_search_term_view.headline', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'dynamic_search_ads_search_term_view.search_term', 'dynamic_search_ads_search_term_view.landing_page', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'segments.week', 'segments.year']
+KEYWORDS_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'ad_group_criterion.approval_status', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy', 'campaign.bidding_strategy_type', 'metrics.bounce_rate', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.quality_info.creative_quality_score', 'ad_group_criterion.keyword.text', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements',
+ # 'campaign.manual_cpc.enhanced_cpc_enabled || campaign.percent_cpc.enhanced_cpc_enabled',
+ 'campaign.manual_cpc.enhanced_cpc_enabled',
+ 'campaign.percent_cpc.enhanced_cpc_enabled',
+ 'ad_group_criterion.position_estimates.estimated_add_clicks_at_first_position_cpc', 'ad_group_criterion.position_estimates.estimated_add_cost_at_first_position_cpc', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_url_suffix', 'ad_group_criterion.final_urls', 'ad_group_criterion.position_estimates.first_page_cpc_micros', 'ad_group_criterion.position_estimates.first_position_cpc_micros', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'metrics.historical_creative_quality_score', 'metrics.historical_landing_page_quality_score', 'metrics.historical_quality_score', 'metrics.historical_search_predicted_ctr', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group_criterion.keyword.match_type',
+ #'Select label.resource_name from the resource ad_group_label',
+ #'Select label.name from the resource ad_group_label',
+ 'label.resource_name',
+ 'label.name',
+ 'segments.month', 'segments.month_of_year', 'metrics.percent_new_visitors', 'ad_group_criterion.quality_info.post_click_quality_score', 'ad_group_criterion.quality_info.quality_score', 'segments.quarter', 'metrics.search_absolute_top_impression_share', 'metrics.search_budget_lost_absolute_top_impression_share', 'metrics.search_budget_lost_top_impression_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'ad_group_criterion.quality_info.search_predicted_ctr', 'metrics.search_rank_lost_absolute_top_impression_share', 'metrics.search_rank_lost_impression_share', 'metrics.search_rank_lost_top_impression_share', 'metrics.search_top_impression_share', 'segments.slot', 'ad_group_criterion.status', 'ad_group_criterion.system_serving_status', 'metrics.top_impression_percentage', 'ad_group_criterion.position_estimates.top_of_page_cpc_micros', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'ad_group_criterion.topic.topic_constant', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+LABEL_REPORT_FIELDS = ['customer.descriptive_name', 'customer.id', 'label.id', 'label.name', 'deprecated']
+LANDING_PAGE_REPORT_FIELDS = ['metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'campaign.advertising_channel_type', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'metrics.conversions_from_interactions_rate', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'expanded_landing_page_view.expanded_final_url', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'metrics.mobile_friendly_clicks_percentage', 'metrics.valid_accelerated_mobile_pages_clicks_percentage', 'segments.quarter', 'segments.slot', 'metrics.speed_score', 'landing_page_view.unexpanded_final_url', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'segments.week', 'segments.year']
+PAID_ORGANIC_QUERY_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'metrics.average_cpc', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'metrics.combined_clicks', 'metrics.combined_clicks_per_query', 'metrics.combined_queries', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'customer.id', 'metrics.impressions', 'segments.keyword.ad_group_criterion', 'segments.keyword.info.text', 'segments.month', 'segments.month_of_year', 'metrics.organic_clicks', 'metrics.organic_clicks_per_query', 'metrics.organic_impressions', 'metrics.organic_impressions_per_query', 'metrics.organic_queries', 'segments.quarter', 'paid_organic_search_term_view.search_term', 'segments.search_engine_results_page_type', 'segments.week', 'segments.year']
+PARENTAL_STATUS_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.parental_status.type', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+PLACEHOLDER_FEED_ITEM_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'ad_group_ad.resource_name', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'feed_item.attribute_values', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device',
+ #'feed_item_target.device is available with FROM feed_item_target',
+ 'feed_item_target.device',
+ #'See feed_item.policy_infos for policy information.',
+ 'feed_item.policy_infos',
+ 'feed_item.end_date_time', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'feed_item.feed', 'feed_item.id', 'feed_item_target.feed_item_target_id', 'feed_item.geo_targeting_restriction', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.interaction_on_this_extension', 'feed_item_target.keyword.match_type', 'feed_item_target.feed_item_target_id', 'feed_item_target.keyword.match_type', 'feed_item_target.keyword.text', 'segments.month', 'segments.month_of_year', 'segments.placeholder_type', 'segments.quarter', 'feed_item_target.ad_schedule', 'segments.slot', 'feed_item.start_date_time', 'feed_item.status', 'feed_item_target.ad_group', 'feed_item_target.campaign', 'feed_item.url_custom_parameters',
+ #'See feed_item.policy_infos for policy information.',
+ 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'segments.week', 'segments.year']
+PLACEHOLDER_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv',
+ #'campaign',
+ 'campaign.id',
+ 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'ad_group_ad.resource_name', 'feed_placeholder_view.placeholder_type', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'segments.slot', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+PLACEMENT_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy', 'bidding_strategy.name', 'bidding_strategy.type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.all_conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.placement.url', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device',
+ #'Returns browser url for websites and for YouTube, video and channel.',
+ # BUG We don't know what to do with this. We looked for a browser url type thing in the query builder
+ 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+PRODUCT_PARTITION_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'metrics.benchmark_average_max_cpc', 'metrics.benchmark_ctr', 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.cpc_bid_micros', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_url_suffix', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'ad_group_criterion.negative', 'segments.month', 'segments.month_of_year', 'ad_group_criterion.listing_group.parent_ad_group_criterion', 'ad_group_criterion.listing_group.type', 'segments.quarter', 'metrics.search_absolute_top_impression_share', 'metrics.search_click_share', 'metrics.search_impression_share', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+# RESOURCES = ['customer', 'ad_group_ad', 'ad_group', 'age_range_view', 'campaign_audience_view', 'group_placement_view', 'bidding_strategy', 'campaign_budget', 'call_view', 'ad_schedule_view', 'campaign_criterion', 'campaign', 'campaign_shared_set', 'location_view', 'click_view', 'display_keyword_view', 'topic_view', 'gender_view', 'geographic_view', 'dynamic_search_ads_search_term_view', 'keyword_view', 'label', 'landing_page_view', 'paid_organic_search_term_view', 'parental_status_view', 'feed_item', 'feed_placeholder_view', 'managed_placement_view', 'product_group_view', 'search_term_view', 'shared_criterion', 'shared_set', 'shopping_performance_view', 'detail_placement_view', 'distance_view', 'video']
+SEARCH_QUERY_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_ad.ad.id', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_ad.ad.final_urls', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.keyword.ad_group_criterion', 'segments.keyword.info.text', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'search_term_view.search_term', 'segments.search_term_match_type', 'search_term_view.status', 'metrics.top_impression_percentage', 'ad_group_ad.ad.tracking_url_template', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+SHARED_SET_CRITERIA_REPORT_FIELDS = ['customer.descriptive_name', 'shared_criterion.keyword.text OR shared_criterion.placement.url, etc.', 'customer.id', 'shared_criterion.criterion_id', 'shared_criterion.keyword.match_type', 'shared_set.id']
+SHOPPING_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'segments.product_aggregator_id', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'segments.product_brand', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.product_bidding_category_level1', 'segments.product_bidding_category_level2', 'segments.product_bidding_category_level3', 'segments.product_bidding_category_level4', 'segments.product_bidding_category_level5', 'segments.product_channel', 'segments.product_channel_exclusivity', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'segments.product_country', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.product_custom_attribute0', 'segments.product_custom_attribute1', 'segments.product_custom_attribute2', 'segments.product_custom_attribute3', 'segments.product_custom_attribute4', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'segments.product_language', 'segments.product_merchant_id', 'segments.month', 'segments.product_item_id', 'segments.product_condition', 'segments.product_title', 'segments.product_type_l1', 'segments.product_type_l2', 'segments.product_type_l3', 'segments.product_type_l4', 'segments.product_type_l5', 'segments.quarter', 'metrics.search_absolute_top_impression_share', 'metrics.search_click_share', 'metrics.search_impression_share', 'segments.product_store_id', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'segments.week', 'segments.year']
+URL_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.all_conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'detail_placement_view.display_name', 'detail_placement_view.group_placement_target_url', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'detail_placement_view.target_url', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+USER_AD_DISTANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'distance_view.distance_bucket', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+VIDEO_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.all_conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_ad.ad.id', 'ad_group_ad.status', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'video.channel_id', 'video.duration_millis', 'video.id', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'video.title', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
diff --git a/tap_google_ads/reports.py b/tap_google_ads/reports.py
new file mode 100644
index 0000000..2a02399
--- /dev/null
+++ b/tap_google_ads/reports.py
@@ -0,0 +1,445 @@
+from collections import defaultdict
+import json
+
+import singer
+from singer import Transformer
+
+from google.protobuf.json_format import MessageToJson
+
+from . import report_definitions
+
+LOGGER = singer.get_logger()
+
+API_VERSION = "v9"
+
+CORE_STREAMS = [
+ "customer",
+ "ad_group",
+ "ad_group_ad",
+ "campaign",
+ "bidding_strategy",
+ "accessible_bidding_strategy",
+ "campaign_budget",
+]
+
+
+def flatten(obj):
+ """Given an `obj` like
+
+ {"a" : {"b" : "c"},
+ "d": "e"}
+
+ return
+
+ {"a.b": "c",
+ "d": "e"}
+ """
+ new_obj = {}
+ for key, value in obj.items():
+ if isinstance(value, dict):
+ for sub_key, sub_value in flatten(value).items():
+ new_obj[f"{key}.{sub_key}"] = sub_value
+ else:
+ new_obj[key] = value
+ return new_obj
+
+
+def make_field_names(resource_name, fields):
+ transformed_fields = []
+ for field in fields:
+ pieces = field.split("_")
+ front = "_".join(pieces[:-1])
+ back = pieces[-1]
+
+ if '.' in field:
+ transformed_fields.append(f"{resource_name}.{field}")
+ elif front in CORE_STREAMS and field.endswith('_id'):
+ transformed_fields.append(f"{front}.{back}")
+ else:
+ transformed_fields.append(f"{resource_name}.{field}")
+ return transformed_fields
+
+
+def transform_keys(resource_name, flattened_obj):
+ transformed_obj = {}
+
+ for field, value in flattened_obj.items():
+ resource_matches = field.startswith(resource_name + ".")
+ is_id_field = field.endswith(".id")
+
+ if resource_matches:
+ new_field_name = ".".join(field.split(".")[1:])
+ elif is_id_field:
+ new_field_name = field.replace(".", "_")
+ else:
+ new_field_name = field
+
+ assert new_field_name not in transformed_obj
+ transformed_obj[new_field_name] = value
+
+ return transformed_obj
+
+class BaseStream:
+ def sync(self, sdk_client, customer, stream):
+ gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
+ resource_name = self.google_ads_resources_name[0]
+ stream_name = stream["stream"]
+ stream_mdata = stream["metadata"]
+ selected_fields = []
+ for mdata in stream_mdata:
+ if (
+ mdata["breadcrumb"]
+ and mdata["metadata"].get("selected")
+ and mdata["metadata"].get("inclusion") == "available"
+ ):
+ selected_fields.append(mdata["breadcrumb"][1])
+
+ google_field_names = make_field_names(resource_name, selected_fields)
+ query = f"SELECT {','.join(google_field_names)} FROM {resource_name}"
+ response = gas.search(query=query, customer_id=customer["customerId"])
+ with Transformer() as transformer:
+ json_response = [
+ json.loads(MessageToJson(x, preserving_proto_field_name=True))
+ for x in response
+ ]
+ for obj in json_response:
+ flattened_obj = flatten(obj)
+ transformed_obj = transform_keys(resource_name, flattened_obj)
+ record = transformer.transform(transformed_obj, stream["schema"])
+ singer.write_record(stream_name, record)
+
+ def add_extra_fields(self, resource_schema):
+ """This function should add fields to `field_exclusions`, `schema`, and
+ `behavior` that are not covered by Google's resource_schema
+ """
+
+ def extract_field_information(self, resource_schema):
+ self.field_exclusions = defaultdict(set)
+ self.schema = {}
+ self.behavior = {}
+ self.selectable = {}
+
+ for resource_name in self.google_ads_resources_name:
+
+ # field_exclusions step
+ fields = resource_schema[resource_name]["fields"]
+ for field_name, field in fields.items():
+ if field_name in self.fields:
+ self.field_exclusions[field_name].update(
+ field["incompatible_fields"]
+ )
+
+ self.schema[field_name] = field["field_details"]["json_schema"]
+
+ self.behavior[field_name] = field["field_details"]["category"]
+
+ self.selectable[field_name] = field["field_details"]["selectable"]
+ self.add_extra_fields(resource_schema)
+ self.field_exclusions = {k: list(v) for k, v in self.field_exclusions.items()}
+
+ def __init__(self, fields, google_ads_resource_name, resource_schema, primary_keys):
+ self.fields = fields
+ self.google_ads_resources_name = google_ads_resource_name
+ self.primary_keys = primary_keys
+ self.extract_field_information(resource_schema)
+
+
+class AdGroupPerformanceReport(BaseStream):
+ def add_extra_fields(self, resource_schema):
+ # from the resource ad_group_ad_label
+ field_name = "label.resource_name"
+ # for field_name in []:
+ self.field_exclusions[field_name] = {}
+ self.schema[field_name] = {"type": ["null", "string"]}
+ self.behavior[field_name] = "ATTRIBUTE"
+
+
+class AdPerformanceReport(BaseStream):
+ def add_extra_fields(self, resource_schema):
+ # from the resource ad_group_ad_label
+ for field_name in ["label.resource_name", "label.name"]:
+ self.field_exclusions[field_name] = {}
+ self.schema[field_name] = {"type": ["null", "string"]}
+ self.behavior[field_name] = "ATTRIBUTE"
+
+ for field_name in [
+ "ad_group_criterion.negative",
+ ]:
+ self.field_exclusions[field_name] = {}
+ self.schema[field_name] = {"type": ["null", "boolean"]}
+ self.behavior[field_name] = "ATTRIBUTE"
+
+
+class AudiencePerformanceReport(BaseStream):
+ "hi"
+ # COMMENT FROM GOOGLE
+ #'bidding_strategy.name must be selected withy the resources bidding_strategy or campaign.',
+
+ # We think this means
+ # `SELECT bidding_strategy.name from bidding_strategy`
+ # Not sure how this applies to the campaign resource
+
+ # COMMENT FROM GOOGLE
+ # 'campaign.bidding_strategy.type must be selected withy the resources bidding_strategy or campaign.'
+
+ # We think this means
+ # `SELECT bidding_strategy.type from bidding_strategy`
+
+ # `SELECT campaign.bidding_strategy_type from campaign`
+
+ # 'user_list.name' is a "Segmenting resource"
+ # `select user_list.name from `
+
+class CampaignPerformanceReport(BaseStream):
+ # TODO: The sync needs to select from campaign_criterion if campaign_criterion.device.type is selected
+ # TODO: The sync needs to select from campaign_label if label.resource_name
+ def add_extra_fields(self, resource_schema):
+ for field_name in [
+ "campaign_criterion.device.type",
+ "label.resource_name",
+ ]:
+ self.field_exclusions[field_name] = set()
+ self.schema[field_name] = {"type": ["null", "string"]}
+ self.behavior[field_name] = "ATTRIBUTE"
+
+
+class DisplayKeywordPerformanceReport(BaseStream):
+ # TODO: The sync needs to select from bidding_strategy and/or campaign if bidding_strategy.name is selected
+ def add_extra_fields(self, resource_schema):
+ for field_name in [
+ "bidding_strategy.name",
+ ]:
+ self.field_exclusions[field_name] = resource_schema[
+ self.google_ads_resources_name[0]
+ ]["fields"][field_name]["incompatible_fields"]
+ self.schema[field_name] = {"type": ["null", "string"]}
+ self.behavior[field_name] = "SEGMENT"
+
+
+class GeoPerformanceReport(BaseStream):
+ # TODO: The sync needs to select from bidding_strategy and/or campaign if bidding_strategy.name is selected
+ def add_extra_fields(self, resource_schema):
+ for resource_name in self.google_ads_resources_name:
+ for field_name in [
+ "country_criterion_id",
+ ]:
+ full_field_name = f"{resource_name}.{field_name}"
+ self.field_exclusions[full_field_name] = (
+ resource_schema[resource_name]["fields"][full_field_name][
+ "incompatible_fields"
+ ]
+ or set()
+ )
+ self.schema[full_field_name] = {"type": ["null", "string"]}
+ self.behavior[full_field_name] = "ATTRIBUTE"
+
+
+class KeywordsPerformanceReport(BaseStream):
+ # TODO: The sync needs to select from ad_group_label if label.name is selected
+ # TODO: The sync needs to select from ad_group_label if label.resource_name is selected
+ def add_extra_fields(self, resource_schema):
+ for field_name in [
+ "label.resource_name",
+ "label.name",
+ ]:
+ self.field_exclusions[field_name] = set()
+ self.schema[field_name] = {"type": ["null", "string"]}
+ self.behavior[field_name] = "ATTRIBUTE"
+
+
+class PlaceholderFeedItemReport(BaseStream):
+ # TODO: The sync needs to select from feed_item_target if feed_item_target.device is selected
+ # TODO: The sync needs to select from feed_item if feed_item.policy_infos is selected
+ def add_extra_fields(self, resource_schema):
+ for field_name in ["feed_item_target.device", "feed_item.policy_infos"]:
+ self.field_exclusions[field_name] = set()
+ self.schema[field_name] = {"type": ["null", "string"]}
+ self.behavior[field_name] = "ATTRIBUTE"
+
+
+def initialize_core_streams(resource_schema):
+ return {
+ "accounts": BaseStream(
+ report_definitions.ACCOUNT_FIELDS,
+ ["customer"],
+ resource_schema,
+ ["customer.id"],
+ ),
+ "ad_groups": BaseStream(
+ report_definitions.AD_GROUP_FIELDS,
+ ["ad_group"],
+ resource_schema,
+ ["ad_group.id"],
+ ),
+ "ads": BaseStream(
+ report_definitions.AD_GROUP_AD_FIELDS,
+ ["ad_group_ad"],
+ resource_schema,
+ ["ad_group_ad.ad.id"],
+ ),
+ "campaigns": BaseStream(
+ report_definitions.CAMPAIGN_FIELDS,
+ ["campaign"],
+ resource_schema,
+ ["campaign.id"],
+ ),
+ "bidding_strategies": BaseStream(
+ report_definitions.BIDDING_STRATEGY_FIELDS,
+ ["bidding_strategy"],
+ resource_schema,
+ ["bidding_strategy.id"],
+ ),
+ "accessible_bidding_strategies": BaseStream(
+ report_definitions.ACCESSIBLE_BIDDING_STRATEGY_FIELDS,
+ ["accessible_bidding_strategy"],
+ resource_schema,
+ ["accessible_bidding_strategy.id"],
+ ),
+ "campaign_budgets": BaseStream(
+ report_definitions.CAMPAIGN_BUDGET_FIELDS,
+ ["campaign_budget"],
+ resource_schema,
+ ["campaign_budget.id"],
+ ),
+ }
+
+
+def initialize_reports(resource_schema):
+ return {
+ "account_performance_report": BaseStream(
+ report_definitions.ACCOUNT_PERFORMANCE_REPORT_FIELDS,
+ ["customer"],
+ resource_schema,
+ ["customer.id"],
+ ),
+ # TODO: This needs to link with ad_group_ad_label
+ "adgroup_performance_report": AdGroupPerformanceReport(
+ report_definitions.ADGROUP_PERFORMANCE_REPORT_FIELDS,
+ ["ad_group"],
+ resource_schema,
+ ["ad_group.id"],
+ ),
+ "ad_performance_report": AdPerformanceReport(
+ report_definitions.AD_PERFORMANCE_REPORT_FIELDS,
+ ["ad_group_ad"],
+ resource_schema,
+ ["ad_group_ad.ad.id"],
+ ),
+ "age_range_performance_report": BaseStream(
+ report_definitions.AGE_RANGE_PERFORMANCE_REPORT_FIELDS,
+ ["age_range_view"],
+ resource_schema,
+ ["ad_group_criterion.criterion_id"],
+ ),
+ "audience_performance_report": AudiencePerformanceReport(
+ report_definitions.AUDIENCE_PERFORMANCE_REPORT_FIELDS,
+ ["campaign_audience_view", "ad_group_audience_view"],
+ resource_schema,
+ ["ad_group_criterion.criterion_id"],
+ ),
+ "call_metrics_call_details_report": BaseStream(
+ report_definitions.CALL_METRICS_CALL_DETAILS_REPORT_FIELDS,
+ ["call_view"],
+ resource_schema,
+ [""],
+ ),
+ "campaign_performance_report": CampaignPerformanceReport(
+ report_definitions.CAMPAIGN_PERFORMANCE_REPORT_FIELDS,
+ ["campaign"],
+ resource_schema,
+ [""],
+ ),
+ "click_performance_report": BaseStream(
+ report_definitions.CLICK_PERFORMANCE_REPORT_FIELDS,
+ ["click_view"],
+ resource_schema,
+ [""],
+ ),
+ "display_keyword_performance_report": DisplayKeywordPerformanceReport(
+ report_definitions.DISPLAY_KEYWORD_PERFORMANCE_REPORT_FIELDS,
+ ["display_keyword_view"],
+ resource_schema,
+ ["ad_group_criterion.criterion_id"],
+ ),
+ "display_topics_performance_report": DisplayKeywordPerformanceReport(
+ report_definitions.DISPLAY_TOPICS_PERFORMANCE_REPORT_FIELDS,
+ ["topic_view"],
+ resource_schema,
+ [""],
+ ),
+ "gender_performance_report": BaseStream(
+ report_definitions.GENDER_PERFORMANCE_REPORT_FIELDS,
+ ["gender_view"],
+ resource_schema,
+ [""],
+ ),
+ "geo_performance_report": GeoPerformanceReport(
+ report_definitions.GEO_PERFORMANCE_REPORT_FIELDS,
+ ["geographic_view", "user_location_view"],
+ resource_schema,
+ [""],
+ ),
+ "keywordless_query_report": BaseStream(
+ report_definitions.KEYWORDLESS_QUERY_REPORT_FIELDS,
+ ["dynamic_search_ads_search_term_view"],
+ resource_schema,
+ [""],
+ ),
+ "keywords_performance_report": KeywordsPerformanceReport(
+ report_definitions.KEYWORDS_PERFORMANCE_REPORT_FIELDS,
+ ["keyword_view"],
+ resource_schema,
+ [""],
+ ),
+ "placeholder_feed_item_report": PlaceholderFeedItemReport(
+ report_definitions.PLACEHOLDER_FEED_ITEM_REPORT_FIELDS,
+ ["feed_item", "feed_item_target"],
+ resource_schema,
+ [""],
+ ),
+ "placeholder_report": BaseStream(
+ report_definitions.PLACEHOLDER_REPORT_FIELDS,
+ ["feed_placeholder_view"],
+ resource_schema,
+ [""],
+ ),
+ "placement_performance_report": BaseStream(
+ report_definitions.PLACEMENT_PERFORMANCE_REPORT_FIELDS,
+ ["managed_placement_view"],
+ resource_schema,
+ [""],
+ ),
+ "search_query_performance_report": BaseStream(
+ report_definitions.SEARCH_QUERY_PERFORMANCE_REPORT_FIELDS,
+ ["search_term_view"],
+ resource_schema,
+ [""],
+ ),
+ "shopping_performance_report": BaseStream(
+ report_definitions.SHOPPING_PERFORMANCE_REPORT_FIELDS,
+ ["shopping_performance_view"],
+ resource_schema,
+ [""],
+ ),
+ "video_performance_report": BaseStream(
+ report_definitions.VIDEO_PERFORMANCE_REPORT_FIELDS,
+ ["video"],
+ resource_schema,
+ [""],
+ ),
+ # "automatic_placements_performance_report": BaseStream(report_definitions.AUTOMATIC_PLACEMENTS_PERFORMANCE_REPORT_FIELDS, ["group_placement_view"], resource_schema),
+ # "bid_goal_performance_report": BaseStream(report_definitions.BID_GOAL_PERFORMANCE_REPORT_FIELDS, ["bidding_strategy"], resource_schema),
+ # "budget_performance_report": BaseStream(report_definitions.BUDGET_PERFORMANCE_REPORT_FIELDS, ["campaign_budget"], resource_schema),
+ # "campaign_ad_schedule_target_report": BaseStream(report_definitions.CAMPAIGN_AD_SCHEDULE_TARGET_REPORT_FIELDS, ["ad_schedule_view"], resource_schema),
+ # "campaign_criteria_report": BaseStream(report_definitions.CAMPAIGN_CRITERIA_REPORT_FIELDS, ["campaign_criterion"], resource_schema),
+ # "campaign_location_target_report": BaseStream(report_definitions.CAMPAIGN_LOCATION_TARGET_REPORT_FIELDS, ["location_view"], resource_schema),
+ # "campaign_shared_set_report": BaseStream(report_definitions.CAMPAIGN_SHARED_SET_REPORT_FIELDS, ["campaign_shared_set"], resource_schema),
+ # "label_report": BaseStream(report_definitions.LABEL_REPORT_FIELDS, ["label"], resource_schema),
+ # "landing_page_report": BaseStream(report_definitions.LANDING_PAGE_REPORT_FIELDS, ["landing_page_view", "expanded_landing_page_view"], resource_schema),
+ # "paid_organic_query_report": BaseStream(report_definitions.PAID_ORGANIC_QUERY_REPORT_FIELDS, ["paid_organic_search_term_view"], resource_schema),
+ # "parental_status_performance_report": BaseStream(report_definitions.PARENTAL_STATUS_PERFORMANCE_REPORT_FIELDS, ["parental_status_view"], resource_schema),
+ # "product_partition_report": BaseStream(report_definitions.PRODUCT_PARTITION_REPORT_FIELDS, ["product_group_view"], resource_schema),
+ # "shared_set_criteria_report": BaseStream(report_definitions.SHARED_SET_CRITERIA_REPORT_FIELDS, ["shared_criterion"], resource_schema),
+ # "url_performance_report": BaseStream(report_definitions.URL_PERFORMANCE_REPORT_FIELDS, ["detail_placement_view"], resource_schema),
+ # "user_ad_distance_report": BaseStream(report_definitions.USER_AD_DISTANCE_REPORT_FIELDS, ["distance_view"], resource_schema),
+ }
diff --git a/tests/base.py b/tests/base.py
index 75c76e6..1691fb3 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -68,127 +68,148 @@ def expected_metadata(self):
return {
# Core Objects
- "accounts": {
- self.PRIMARY_KEYS: {"id"},
+ "Accounts": {
+ self.PRIMARY_KEYS: {"customer.id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
},
- "campaigns": {
- self.PRIMARY_KEYS: {"id"},
+ "Campaigns": {
+ self.PRIMARY_KEYS: {"campaign.id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
},
- "ad_groups": {
- self.PRIMARY_KEYS: {"id"},
+ "Ad_Groups": {
+ self.PRIMARY_KEYS: {"ad_group.id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
},
- "ads": {
- self.PRIMARY_KEYS: {"id"},
+ "Ads": {
+ self.PRIMARY_KEYS: {"ad_group_ad.ad.id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- }
- # # Standard Reports
- # "ACCOUNT_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
- # },
- # "ADGROUP_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ },
+ # "age_range_view":{},
+ # "campaign_audience_view":{},
+ # "call_view":{},
+ # "click_view":{},
+ # "display_keyword_view":{},
+ # "topic_view":{},
+ # "gender_view":{},
+ # "geographic_view":{},
+ # "user_location_view":{},
+ # "dynamic_search_ads_search_term_view":{},
+ # "keyword_view":{},
+ # "landing_page_view":{},
+ # "expanded_landing_page_view":{},
+ # "feed_item":{},
+ # "feed_item_target":{},
+ # "feed_placeholder_view":{},
+ # "managed_placement_view":{},
+ # "search_term_view":{},
+ # "shopping_performance_view":{},
+ # "video":{},
+
+ # Standard Reports
+ # "Account_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "AD_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Adgroup_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "AGE_RANGE_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Ad_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "AUDIENCE_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Age_Range_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "CALL_METRICS_CALL_DETAILS_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Audience_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "CAMPAIGN_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Call_Metrics_Call_Details_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "CLICK_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Campaign_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "CRITERIA_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Click_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "DISPLAY_KEYWORD_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # # "Criteria_Performance_Report": {
+ # # self.PRIMARY_KEYS: {"TODO"},
+ # # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # # self.REPLICATION_KEYS: {"date"},
+ # # },
+ # "Display_Keyword_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "DISPLAY_TOPICS_PERFORMANCE_REPORTFINAL_URL_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Display_Topics_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "GENDER_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Gender_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "GEO_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Geo_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "KEYWORDLESS_QUERY_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Keywordless_Query_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "KEYWORDS_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Keywords_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "PLACEHOLDER_FEED_ITEM_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Placeholder_Feed_Item_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "PLACEHOLDER_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Placeholder_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "PLACEMENT_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Placement_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "SEARCH_QUERY_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Search_Query_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "SHOPPING_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Shopping_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
- # "VIDEO_PERFORMANCE_REPORT": {
- # self.PRIMARY_KEYS: {"TODO"},
+ # "Video_Performance_Report": {
+ # self.PRIMARY_KEYS: set(),
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
+ # self.REPLICATION_KEYS: set(),
# },
# # Custom Reports TODO
}
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index 88a8f13..49fb44f 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -44,7 +44,7 @@ def test_run(self):
# streams should only have lowercase alphas and underscores
found_catalog_names = {c['tap_stream_id'] for c in found_catalogs}
- self.assertTrue(all([re.fullmatch(r"[a-z_]+", name) for name in found_catalog_names]),
+ self.assertTrue(all([re.fullmatch(r"[A-Za-z_]+", name) for name in found_catalog_names]),
msg="One or more streams don't follow standard naming")
for stream in streams_to_test:
@@ -122,7 +122,7 @@ def test_run(self):
# verify all other fields are given inclusion of available
self.assertTrue(
- all({item.get("metadata").get("inclusion") == "available"
+ all({item.get("metadata").get("inclusion") in {"available", "unsupported"}
for item in metadata
if item.get("breadcrumb", []) != []
and item.get("breadcrumb", ["properties", None])[1]
diff --git a/tests/unittests/test_resource_schema.py b/tests/unittests/test_resource_schema.py
new file mode 100644
index 0000000..6d72e3d
--- /dev/null
+++ b/tests/unittests/test_resource_schema.py
@@ -0,0 +1,101 @@
+from collections import namedtuple
+import unittest
+from tap_google_ads import get_segments
+from tap_google_ads import get_attributes
+
+
+RESOURCE_SCHEMA = {
+ "template": {
+ "category": "",
+ "attributes": [],
+ "segments": []
+ },
+ "resource1": {
+ "category": "RESOURCE",
+ "attributes": ["resource1.thing3", "resource1.thing4"],
+ "segments": []
+ }
+
+}
+
+
+class TestGetSegments(unittest.TestCase):
+ def test_get_segments_on_a_non_resource(self):
+ data_types = ["ATTRIBUTE", "SEGMENT", "METRIC"]
+ for data_type in data_types:
+ with self.subTest(data_type=data_type):
+ resource = {
+ "category": data_type,
+ "attributes": ["attribute1"],
+ "segments": ["segment1"]
+ }
+
+ actual = get_segments(RESOURCE_SCHEMA, resource)
+
+ expected = []
+
+ self.assertListEqual(expected, actual)
+
+ def test_get_segments_on_a_resource_with_only_dot_segments(self):
+ resource = {
+ "category": "RESOURCE",
+ "attributes": ["attribute1"],
+ "segments": ["segments.thing1", "segments.thing2"]
+ }
+
+ actual = get_segments(RESOURCE_SCHEMA, resource)
+
+ expected = ["segments.thing1", "segments.thing2"]
+
+ self.assertListEqual(expected, actual)
+
+ def test_get_segments_on_a_resource_with_dot_segments_and_segmenting_resource(self):
+ resource = {
+ "category": "RESOURCE",
+ "attributes": ["attribute1"],
+ "segments": ["segments.thing1", "segments.thing2", "resource1"]
+ }
+
+ actual = get_segments(RESOURCE_SCHEMA, resource)
+
+ expected = ["segments.thing1", "segments.thing2", "resource1.thing3", "resource1.thing4"]
+
+ self.assertListEqual(expected, actual)
+
+
+api_object = namedtuple("api_object", "category attribute_resources name")
+
+class TestGetAttributes(unittest.TestCase):
+ def test_get_attributes_on_a_non_resource(self):
+ api_objects = [
+ api_object(3, [], "resource.attr1"),
+ api_object(3, [], "resource.attr2"),
+ api_object(3, [], "resource.attr3"),
+ ]
+ data_types = [0, 1, 2, 3, 5, 6]
+ for data_type in data_types:
+ with self.subTest(data_type=data_type):
+ resource = api_object(data_type, [], "resource.attr")
+ actual = get_attributes(api_objects, resource)
+
+ expected = []
+
+ self.assertListEqual(expected, actual)
+
+ def test_get_attributes_on_a_resource(self):
+ api_objects = [
+ api_object(3, [], "resource.attr1"),
+ api_object(3, [], "resource.attr2"),
+ api_object(3, [], "resource.attr3"),
+ ]
+ resource = api_object(2, [], "resource")
+
+ actual = get_attributes(api_objects, resource)
+
+ expected = ["resource.attr1", "resource.attr2", "resource.attr3"]
+
+ self.assertListEqual(expected, actual)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unittests/test_utils.py b/tests/unittests/test_utils.py
new file mode 100644
index 0000000..5f5fc0c
--- /dev/null
+++ b/tests/unittests/test_utils.py
@@ -0,0 +1,44 @@
+import unittest
+from tap_google_ads.reports import flatten
+from tap_google_ads.reports import make_field_names
+
+
+
+class TestFlatten(unittest.TestCase):
+ def test_flatten_one_level(self):
+ nested_obj = {"a": {"b": "c"}, "d": "e"}
+ actual = flatten(nested_obj)
+ expected = {"a.b": "c", "d": "e"}
+ self.assertDictEqual(expected, actual)
+
+ def test_flatten_two_levels(self):
+ nested_obj = {"a": {"b": {"c": "d", "e": "f"}, "g": "h"}}
+ actual = flatten(nested_obj)
+ expected = {"a.b.c": "d", "a.b.e": "f", "a.g": "h"}
+ self.assertDictEqual(expected, actual)
+
+
+class TestMakeFieldNames(unittest.TestCase):
+ def test_single_word(self):
+ actual = make_field_names("resource", ["type"])
+ expected = ["resource.type"]
+ self.assertListEqual(expected, actual)
+
+ def test_dotted_field(self):
+ actual = make_field_names("resource", ["tracking_setting.tracking_url"])
+ expected = ["resource.tracking_setting.tracking_url"]
+ self.assertListEqual(expected, actual)
+
+ def test_foreign_key_field(self):
+ actual = make_field_names("resource", ["customer_id", "accessible_bidding_strategy_id"])
+ expected = ["customer.id", "accessible_bidding_strategy.id"]
+ self.assertListEqual(expected, actual)
+
+ def test_trailing_id_field(self):
+ actual = make_field_names("resource", ["owner_customer_id"])
+ expected = ["resource.owner_customer_id"]
+ self.assertListEqual(expected, actual)
+
+
+if __name__ == '__main__':
+ unittest.main()
From f5db886a4d10d0cad4c4da09f2fd0c991e2efe86 Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Fri, 28 Jan 2022 10:43:35 -0500
Subject: [PATCH 17/69] Qa/disco test (#11)
* WIP Discovery Testing (fields for accounts and campaigns)
* added src code hack so tests run
* added a canary sync for core streams
* pylint fix
* added replication test - core objects
Co-authored-by: kspeer
---
tap_google_ads/__init__.py | 11 +-
tests/base.py | 342 ++++++++++++++++-----------
tests/test_google_ads_bookmarks.py | 137 +++++++++++
tests/test_google_ads_discovery.py | 312 ++++++++++++++++++++++--
tests/test_google_ads_sync_canary.py | 78 ++++++
5 files changed, 715 insertions(+), 165 deletions(-)
create mode 100644 tests/test_google_ads_bookmarks.py
create mode 100644 tests/test_google_ads_sync_canary.py
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 4c4d52c..5775d5b 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -303,7 +303,7 @@ def create_sdk_client(config, login_customer_id=None):
"developer_token": config["developer_token"],
"client_id": config["oauth_client_id"],
"client_secret": config["oauth_client_secret"],
- "access_token": config["access_token"],
+ # "access_token": config["access_token"], # BUG? REMOVE ME!
"refresh_token": config["refresh_token"],
}
@@ -315,7 +315,12 @@ def create_sdk_client(config, login_customer_id=None):
def do_sync(config, catalog, resource_schema):
- customers = json.loads(config["login_customer_ids"])
+ # QA ADDED WORKAROUND [START]
+ try:
+ customers = json.loads(config["login_customer_ids"])
+ except TypeError: # falling back to raw value
+ customers = config["login_customer_ids"]
+ # QA ADDED WORKAROUND [END]
selected_streams = [
stream
@@ -399,7 +404,7 @@ def get_client_config(config, login_customer_id=None):
"client_id": config["oauth_client_id"],
"client_secret": config["oauth_client_secret"],
"refresh_token": config["refresh_token"],
- # "access_token": config["access_token"],
+ # "access_token": config["access_token"], # BUG? REMOVE ME
}
if login_customer_id:
diff --git a/tests/base.py b/tests/base.py
index 1691fb3..3ded80a 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -4,6 +4,7 @@
"""
import unittest
import os
+import json
from datetime import timedelta
from datetime import datetime as dt
@@ -47,12 +48,12 @@ def get_properties(self):
'customer_ids': '5548074409,2728292456',
'login_customer_ids': [
{
- 'customerId': '5548074409',
- 'loginCustomerId': '2728292456',
+ "customerId": "5548074409",
+ "loginCustomerId": "2728292456",
},
{
- 'customerId': '2728292456',
- 'loginCustomerId': '2728292456',
+ "customerId": "2728292456",
+ "loginCustomerId": "2728292456",
},
],
}
@@ -68,154 +69,182 @@ def expected_metadata(self):
return {
# Core Objects
- "Accounts": {
- self.PRIMARY_KEYS: {"customer.id"},
+ "accounts": {
+ self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: set(),
},
- "Campaigns": {
- self.PRIMARY_KEYS: {"campaign.id"},
+ "campaigns": {
+ self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ 'accessible_bidding_strategy_id',
+ 'bidding_strategy_id',
+ 'campaign_budget_id',
+ 'customer_id'
+ },
},
- "Ad_Groups": {
- self.PRIMARY_KEYS: {"ad_group.id"},
+ "ad_groups": {
+ self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ 'accessible_bidding_strategy_id',
+ 'bidding_strategy_id',
+ 'campaign_id',
+ 'customer_id',
+ },
},
- "Ads": {
- self.PRIMARY_KEYS: {"ad_group_ad.ad.id"},
+ "ads": {
+ self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ "campaign_id",
+ "customer_id",
+ "ad_group_id"
+ },
},
- # "age_range_view":{},
- # "campaign_audience_view":{},
- # "call_view":{},
- # "click_view":{},
- # "display_keyword_view":{},
- # "topic_view":{},
- # "gender_view":{},
- # "geographic_view":{},
- # "user_location_view":{},
- # "dynamic_search_ads_search_term_view":{},
- # "keyword_view":{},
- # "landing_page_view":{},
- # "expanded_landing_page_view":{},
- # "feed_item":{},
- # "feed_item_target":{},
- # "feed_placeholder_view":{},
- # "managed_placement_view":{},
- # "search_term_view":{},
- # "shopping_performance_view":{},
- # "video":{},
-
- # Standard Reports
- # "Account_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Adgroup_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Ad_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Age_Range_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Audience_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Call_Metrics_Call_Details_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Campaign_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Click_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # # "Criteria_Performance_Report": {
- # # self.PRIMARY_KEYS: {"TODO"},
- # # self.REPLICATION_METHOD: self.INCREMENTAL,
- # # self.REPLICATION_KEYS: {"date"},
- # # },
- # "Display_Keyword_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Display_Topics_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Gender_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Geo_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Keywordless_Query_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Keywords_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Placeholder_Feed_Item_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Placeholder_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Placement_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Search_Query_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
- # },
- # "Shopping_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
+ 'campaign_budgets': {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {"customer_id"},
+ },
+ 'bidding_strategies': {
+ self.PRIMARY_KEYS:{"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {"customer_id"},
+ },
+ 'accessible_bidding_strategies': {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {"customer_id"},
+ },
+ # Report objects
+ "age_range_performance_report": { # "age_range_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "audience_performance_report": { # "campaign_audience_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "campaign_performance_report": { # "campaign_audience_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "call_metrics_call_details_report": { # "call_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "click_performance_report": { # "click_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "display_keyword_performance_report": { # "display_keyword_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "display_topics_performance_report": { # "topic_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "gender_performance_report": { # "gender_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "geo_performance_report": { # "geographic_view", "user_location_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "keywordless_query_report": { # "dynamic_search_ads_search_term_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "keywords_performance_report": { # "keyword_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ # TODO Do the land page reports have a different name in UI from the resource?
+ # TODO should they follow the _report naming convention
+ "landing_page_report": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "expanded_landing_page_report": {
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "placeholder_feed_item_report": { # "feed_item", "feed_item_target"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "placeholder_report": { # "feed_placeholder_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "placement_performance_report": { # "managed_placement_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "search_query_performance_report": { # "search_term_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "shopping_performance_report": { # "shopping_performance_view"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "video_performance_report": { # "video"
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ # MISSING V1 reports
+ "account_performance_report": { # accounts
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "adgroup_performance_report": { # ad_group
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "ad_performance_report": { # ads
+ self.PRIMARY_KEYS: {"TODO"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ # "criteria_performance_report": { # DEPRECATED TODO maybe possilbe?
+ # self.PRIMARY_KEYS: {"TODO"},
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
+ # self.REPLICATION_KEYS: {"date"},
# },
- # "Video_Performance_Report": {
- # self.PRIMARY_KEYS: set(),
+ # "final_url_report": { # DEPRECATED Replaced with landing page / expanded landing page
+ # self.PRIMARY_KEYS: {},
# self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: set(),
+ # self.REPLICATION_KEYS: {"date"},
# },
- # # Custom Reports TODO
+ # Custom Reports TODO feature
}
-
-
def expected_streams(self):
"""A set of expected stream names"""
return set(self.expected_metadata().keys())
@@ -231,6 +260,15 @@ def expected_streams(self):
# return {stream for stream, metadata in self.expected_metadata().items()
# if metadata.get(self.FOREIGN_KEYS)}
+ def expected_foreign_keys(self):
+ """
+ return a dictionary with key of table name
+ and value as a set of foreign key fields
+ """
+ return {table: properties.get(self.FOREIGN_KEYS, set())
+ for table, properties
+ in self.expected_metadata().items()}
+
def expected_primary_keys(self):
"""
return a dictionary with key of table name
@@ -315,7 +353,7 @@ def run_and_verify_sync(self, conn_id):
# Verify actual rows were synced
sync_record_count = runner.examine_target_output_file(
- self, conn_id, self.expected_sync_streams(), self.expected_primary_keys())
+ self, conn_id, self.expected_streams(), self.expected_primary_keys())
self.assertGreater(
sum(sync_record_count.values()), 0,
msg="failed to replicate any data: {}".format(sync_record_count)
@@ -340,11 +378,12 @@ def perform_and_verify_table_and_field_selection(self, conn_id, test_catalogs,
"""
# Select all available fields or select no fields from all testable streams
- self._select_streams_and_fields(
- conn_id=conn_id, catalogs=test_catalogs,
- select_default_fields=select_default_fields,
- select_pagination_fields=select_pagination_fields
- )
+ self.select_all_streams_and_fields(conn_id, test_catalogs, True)
+ # self._select_streams_and_fields(
+ # conn_id=conn_id, catalogs=test_catalogs,
+ # select_default_fields=select_default_fields,
+ # select_pagination_fields=select_pagination_fields
+ # )
catalogs = menagerie.get_catalogs(conn_id)
@@ -389,6 +428,21 @@ def _get_selected_fields_from_metadata(metadata):
selected_fields.add(field['breadcrumb'][1])
return selected_fields
+ @staticmethod
+ def select_all_streams_and_fields(conn_id, catalogs, select_all_fields: bool = True):
+ """Select all streams and all fields within streams"""
+ for catalog in catalogs:
+ schema = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
+
+ non_selected_properties = []
+ if not select_all_fields:
+ # get a list of all properties so that none are selected
+ non_selected_properties = schema.get('annotated-schema', {}).get(
+ 'properties', {}).keys()
+
+ connections.select_catalog_and_fields_via_metadata(
+ conn_id, catalog, schema, [], non_selected_properties)
+
def _select_streams_and_fields(self, conn_id, catalogs, select_default_fields, select_pagination_fields):
"""Select all streams and all fields within streams"""
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
new file mode 100644
index 0000000..3d3b095
--- /dev/null
+++ b/tests/test_google_ads_bookmarks.py
@@ -0,0 +1,137 @@
+"""Test tap discovery mode and metadata."""
+import re
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class DiscoveryTest(GoogleAdsBase):
+ """Test tap discovery mode and metadata conforms to standards."""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_bookmarks"
+
+ def test_run(self):
+ """
+ Testing that basic sync functions without Critical Errors
+ """
+ print("Bookmarks Test for tap-google-ads")
+
+ conn_id = connections.ensure_connection(self)
+
+ streams_to_test = self.expected_streams() - {
+ # TODO we are only testing core strems at the moment
+ 'landing_page_report',
+ 'expanded_landing_page_report',
+ 'display_topics_performance_report',
+ 'call_metrics_call_details_report',
+ 'gender_performance_report',
+ 'search_query_performance_report',
+ 'placeholder_feed_item_report',
+ 'keywords_performance_report',
+ 'video_performance_report',
+ 'campaign_performance_report',
+ 'geo_performance_report',
+ 'placeholder_report',
+ 'placement_performance_report',
+ 'click_performance_report',
+ 'display_keyword_performance_report',
+ 'shopping_performance_report',
+ 'ad_performance_report',
+ 'age_range_performance_report',
+ 'keywordless_query_report',
+ 'account_performance_report',
+ 'adgroup_performance_report',
+ 'audience_performance_report',
+ }
+
+ # Run a discovery job
+ check_job_name = runner.run_check_mode(self, conn_id)
+ exit_status = menagerie.get_exit_status(conn_id, check_job_name)
+ menagerie.verify_check_exit_status(self, exit_status, check_job_name)
+
+ # Verify a catalog was produced for each stream under test
+ found_catalogs = menagerie.get_catalogs(conn_id)
+ self.assertGreater(len(found_catalogs), 0)
+ found_catalog_names = {found_catalog['stream_name'] for found_catalog in found_catalogs}
+ self.assertSetEqual(streams_to_test, found_catalog_names)
+
+ # Perform table and field selection
+ self.select_all_streams_and_fields(conn_id, found_catalogs, select_all_fields=True)
+
+
+ # Run a sync
+ sync_job_name_1 = runner.run_sync_mode(self, conn_id)
+
+ # Verify the tap and target do not throw a critical error
+ exit_status_1 = menagerie.get_exit_status(conn_id, sync_job_name_1)
+ menagerie.verify_sync_exit_status(self, exit_status_1, sync_job_name_1)
+
+ # acquire records from target output
+ synced_records_1 = runner.get_records_from_target_output()
+ state_1 = menagerie.get_state(conn_id)
+
+ # Run another sync
+ sync_job_name_2 = runner.run_sync_mode(self, conn_id)
+
+ # Verify the tap and target do not throw a critical error
+ exit_status_2 = menagerie.get_exit_status(conn_id, sync_job_name_2)
+ menagerie.verify_sync_exit_status(self, exit_status_2, sync_job_name_2)
+
+ # acquire records from target output
+ synced_records_2 = runner.get_records_from_target_output()
+ state_2 = menagerie.get_state(conn_id)
+
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ # set expectations
+ expected_replication_method = self.expected_replication_method()[stream]
+
+ # gather results
+ records_1 = [message['data'] for message in synced_records_1[stream]['messages']]
+ records_2 = [message['data'] for message in synced_records_2[stream]['messages']]
+ record_count_1 = len(records_1)
+ record_count_2 = len(records_2)
+ bookmarks_1 = state_1.get(stream)
+ bookmarks_2 = state_2.get(stream)
+
+ # sanity check WIP
+ print(f"Stream: {stream} \n"
+ f"Record 1 Sync 1: {records_1[0]}")
+ # end WIP
+
+ if expected_replication_method == self.INCREMENTAL:
+
+ # included to catch a contradiction in our base expectations
+ if not stream.endswith('_report'):
+ raise AssertionError(
+ f"Only Reports streams should be expected to support {expected_replication_method} replication."
+ )
+
+ # TODO need to finish implementing test cases for report streams
+
+ elif expected_replication_method == self.FULL_TABLE:
+
+ # Verify full table streams replicate the same number of records on each sync
+ self.assertEqual(record_count_1, record_count_2)
+
+ # Verify full table streams do not save bookmarked values at the conclusion of a succesful sync
+ self.assertIsNone(bookmarks_1)
+ self.assertIsNone(bookmarks_2)
+
+ # Verify full table streams replicate the same number of records on each sync
+ self.assertEqual(record_count_1, record_count_2)
+
+ # Verify full tables streams replicate the exact same set of records on each sync
+ for record in records_1:
+ self.assertIn(record, records_2)
+
+ # Verify at least 1 record was replicated for each stream
+ self.assertGreater(record_count_1, 0)
+
+
+ print(f"{stream} {record_count_1} records replicated.")
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index 49fb44f..d74db61 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -9,6 +9,233 @@
class DiscoveryTest(GoogleAdsBase):
"""Test tap discovery mode and metadata conforms to standards."""
+ def expected_fields(self):
+ """The expected streams and metadata about the streams"""
+ # TODO verify accounts, ads, ad_groups, campaigns contain foreign keys for
+ # 'campaign_budgets', 'bidding_strategies', 'accessible_bidding_strategies'
+ # and only foreign keys BUT CHECK DOCS
+
+ return {
+ # Core Objects
+ "accounts": { # TODO check with Brian on changes
+ # OLD FIELDS (with mapping)
+ "currency_code",
+ "id", # "customer_id",
+ "manager", # "can_manage_clients",
+ "resource_name", # name -- unclear if this actually the mapping
+ "test_account",
+ "time_zone", #"date_time_zone",
+ # NEW FIELDS
+ 'auto_tagging_enabled',
+ 'call_reporting_setting.call_conversion_action',
+ 'call_reporting_setting.call_conversion_reporting_enabled',
+ 'call_reporting_setting.call_reporting_enabled',
+ 'conversion_tracking_setting.conversion_tracking_id',
+ 'conversion_tracking_setting.cross_account_conversion_tracking_id',
+ 'descriptive_name',
+ 'final_url_suffix',
+ 'has_partners_badge',
+ 'optimization_score',
+ 'optimization_score_weight',
+ 'pay_per_conversion_eligibility_failure_reasons',
+ 'remarketing_setting.google_global_site_tag',
+ 'tracking_url_template',
+ },
+ "campaigns": { # TODO check out nested keys once these are satisfied
+ # OLD FIELDS
+ "ad_serving_optimization_status",
+ "advertising_channel_type",
+ "base_campaign", # Was "base_campaign_id",
+ "campaign_budget_id", # Was "budget_id",
+ "end_date",
+ "experiment_type", # Was campaign_trial_type",
+ "frequency_caps", # Was frequency_cap",
+ "id",
+ "labels",
+ "name",
+ "network_settings.target_content_network", # Was network_setting
+ "network_settings.target_google_search", # Was network_setting
+ "network_settings.target_partner_search_network", # Was network_setting
+ "network_settings.target_search_network", # Was network_setting
+ "serving_status",
+ "start_date",
+ "status",
+ "url_custom_parameters",
+ #"conversion_optimizer_eligibility", # No longer present
+ #"settings", # No clear mapping to replacement
+ # NEW FIELDS
+ "accessible_bidding_strategy",
+ "accessible_bidding_strategy_id",
+ "advertising_channel_sub_type",
+ "app_campaign_setting.app_id",
+ "app_campaign_setting.app_store",
+ "app_campaign_setting.bidding_strategy_goal_type",
+ "bidding_strategy",
+ "bidding_strategy_id",
+ "bidding_strategy_type",
+ "campaign_budget",
+ "commission.commission_rate_micros",
+ "customer_id",
+ "dynamic_search_ads_setting.domain_name",
+ "dynamic_search_ads_setting.feeds",
+ "dynamic_search_ads_setting.language_code",
+ "dynamic_search_ads_setting.use_supplied_urls_only",
+ "excluded_parent_asset_field_types",
+ "final_url_suffix",
+ "geo_target_type_setting.negative_geo_target_type",
+ "geo_target_type_setting.positive_geo_target_type",
+ "hotel_setting.hotel_center_id",
+ "local_campaign_setting.location_source_type",
+ "manual_cpc.enhanced_cpc_enabled",
+ "manual_cpm",
+ "manual_cpv",
+ "maximize_conversion_value.target_roas",
+ "maximize_conversions.target_cpa",
+ "optimization_goal_setting.optimization_goal_types",
+ "optimization_score",
+ "payment_mode",
+ "percent_cpc.cpc_bid_ceiling_micros",
+ "percent_cpc.enhanced_cpc_enabled",
+ "real_time_bidding_setting.opt_in",
+ "resource_name",
+ "selective_optimization.conversion_actions",
+ "shopping_setting.campaign_priority",
+ "shopping_setting.campaign_priority",
+ "shopping_setting.enable_local",
+ "shopping_setting.merchant_id",
+ "shopping_setting.sales_country",
+ "target_cpa.cpc_bid_ceiling_micros",
+ "target_cpa.cpc_bid_floor_micros",
+ "target_cpa.target_cpa_micros",
+ "target_cpm",
+ "target_impression_share.cpc_bid_ceiling_micros",
+ "target_impression_share.location",
+ "target_impression_share.location_fraction_micros",
+ "target_roas.cpc_bid_ceiling_micros",
+ "target_roas.cpc_bid_floor_micros",
+ "target_roas.target_roas",
+ "target_spend.cpc_bid_ceiling_micros",
+ "target_spend.target_spend_micros",
+ "targeting_setting.target_restrictions",
+ "tracking_setting.tracking_url",
+ "tracking_url_template",
+ "url_expansion_opt_out",
+ "vanity_pharma.vanity_pharma_display_url_mode",
+ "vanity_pharma.vanity_pharma_text",
+ "video_brand_safety_suitability",
+ },
+ "ad_groups": { # TODO check out nested keys once these are satisfied
+ # OLD FIELDS (with mappings)
+ "type", # ("ad_group_type")
+ "base_ad_group", # ("base_ad_group_id")
+ # "bidding_strategy_configuration", # DNE
+ "campaign", #("campaign_name", "campaign_id", "base_campaign_id") # TODO redo this
+ "id",
+ "labels",
+ "name",
+ # "settings", # DNE
+ "status",
+ "url_custom_parameters",
+ # NEW FIELDS
+ 'resource_name',
+ "tracking_url_template",
+ "cpv_bid_micros",
+ "campaign_id",
+ "effective_target_cpa_micros",
+ "display_custom_bid_dimension",
+ "bidding_strategy_id",
+ "target_cpm_micros",
+ "explorer_auto_optimizer_setting.opt_in",
+ "effective_target_cpa_source",
+ "accessible_bidding_strategy_id",
+ "excluded_parent_asset_field_types",
+ "final_url_suffix",
+ "percent_cpc_bid_micros",
+ "effective_target_roas_source",
+ "ad_rotation_mode",
+ "targeting_setting.target_restrictions",
+ "cpm_bid_micros",
+ "customer_id",
+ "cpc_bid_micros",
+ "target_roas",
+ "target_cpa_micros",
+ "effective_target_roas",
+ },
+ "ads": { # TODO check out nested keys once these are satisfied
+ # OLD FIELDS (with mappings)
+ "ad_group_id",
+ "base_ad_group_id",
+ "base_campaign_id",
+ 'policy_summary.policy_topic_entries', # ("policy_summary")
+ 'policy_summary.review_status', # ("policy_summary")
+ 'policy_summary.approval_status', # ("policy_summary")
+ "status",
+ # "trademark_disapproved", # DNE
+ # NEW FIELDS
+ },
+ 'campaign_budgets': {
+ "budget_id",
+ },
+ 'bidding_strategies': {
+ "bids", # comparablevalue.type, microamount,
+ "bid_source",
+ "bids.type",
+ },
+ 'accessible_bidding_strategies': {
+ "bids", # comparablevalue.type, microamount,
+ "bid_source",
+ "bids.type",
+ },
+ # Report objects
+ "age_range_performance_report": { # "age_range_view"
+ },
+ "audience_performance_report": { # "campaign_audience_view"
+ },
+ "campaign_performance_report": { # "campaign_audience_view"
+ },
+ "call_metrics_call_details_report": { # "call_view"
+ },
+ "click_performance_report": { # "click_view"
+ },
+ "display_keyword_performance_report": { # "display_keyword_view"
+ },
+ "display_topics_performance_report":{ # "topic_view"
+ },
+ "": { # "topic_view" todo consult https://developers.google.com/google-ads/api/docs/migration/url-reports for migrating this report
+ },
+ "gender_performance_report": { # "gender_view"
+ },
+ "geo_performance_report": { # "geographic_view", "user_location_view"
+ },
+ "keywordless_query_report": { # "dynamic_search_ads_search_term_view"
+ },
+ "keywords_performance_report": { # "keyword_view"
+ },
+ "landing_page_view": { # was final_url_report
+ },
+ "expanded_landing_page_view": { # was final_url_report
+ },
+ "placeholder_feed_item_report": { # "feed_item", "feed_item_target"
+ },
+ "placeholder_report": { # "feed_placeholder_view"
+ },
+ "placement_performance_report": { # "managed_placement_view"
+ },
+ "search_query_performance_report": { # "search_term_view"
+ },
+ "shopping_performance_report": { # "shopping_performance_view"
+ },
+ "video_performance_report": { # "video"
+ },
+ "account_performance_report": { # accounts
+ },
+ "adgroup_performance_report": { # ad_group
+ },
+ "ad_performance_report": { # ads
+ },
+ # Custom Reports TODO feature
+ }
+
@staticmethod
def name():
return "tt_google_ads_disco"
@@ -34,20 +261,50 @@ def test_run(self):
conn_id = connections.ensure_connection(self)
- streams_to_test = self.expected_streams()
-
- found_catalogs = self.run_and_verify_check_mode(conn_id)
+ streams_to_test = self.expected_streams() - {
+ # BUG_2 | missing
+ 'landing_page_report',
+ 'expanded_landing_page_report',
+ 'display_topics_performance_report',
+ 'call_metrics_call_details_report',
+ 'gender_performance_report',
+ 'search_query_performance_report',
+ 'placeholder_feed_item_report',
+ 'keywords_performance_report',
+ 'video_performance_report',
+ 'campaign_performance_report',
+ 'geo_performance_report',
+ 'placeholder_report',
+ 'placement_performance_report',
+ 'click_performance_report',
+ 'display_keyword_performance_report',
+ 'shopping_performance_report',
+ 'ad_performance_report',
+ 'age_range_performance_report',
+ 'keywordless_query_report',
+ 'account_performance_report',
+ 'adgroup_performance_report',
+ 'audience_performance_report',
+ }
- print(f"found_catalogs: {found_catalogs}")
+ # found_catalogs = self.run_and_verify_check_mode(conn_id) # TODO PUT BACK
+ # TODO REMOVE FROM HERE
+ check_job_name = runner.run_check_mode(self, conn_id)
+ exit_status = menagerie.get_exit_status(conn_id, check_job_name)
+ menagerie.verify_check_exit_status(self, exit_status, check_job_name)
+ found_catalogs = menagerie.get_catalogs(conn_id)
+ self.assertGreater(len(found_catalogs), 0)
+ found_catalog_names = {found_catalog['stream_name'] for found_catalog in found_catalogs}
+ self.assertSetEqual(streams_to_test, found_catalog_names)
+ # TODO TO HERE
# Verify stream names follow naming convention
# streams should only have lowercase alphas and underscores
-
found_catalog_names = {c['tap_stream_id'] for c in found_catalogs}
- self.assertTrue(all([re.fullmatch(r"[A-Za-z_]+", name) for name in found_catalog_names]),
+ self.assertTrue(all([re.fullmatch(r"[a-z_]+", name) for name in found_catalog_names]),
msg="One or more streams don't follow standard naming")
- for stream in streams_to_test:
+ for stream in streams_to_test: # {'accounts', 'campaigns', 'ad_groups', 'ads'}: # # TODO PUT BACK
with self.subTest(stream=stream):
# Verify the catalog is found for a given stream
@@ -57,10 +314,11 @@ def test_run(self):
# collecting expected values
expected_primary_keys = self.expected_primary_keys()[stream]
- #expected_foreign_keys = self.expected_foreign_keys()[stream]
+ expected_foreign_keys = self.expected_foreign_keys()[stream]
expected_replication_keys = self.expected_replication_keys()[stream]
expected_automatic_fields = expected_primary_keys | expected_replication_keys
expected_replication_method = self.expected_replication_method()[stream]
+ expected_fields = self.expected_fields()[stream]
# collecting actual values
schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
@@ -82,7 +340,7 @@ def test_run(self):
"metadata", {self.REPLICATION_METHOD: None}).get(self.REPLICATION_METHOD)
actual_automatic_fields = set(
item.get("breadcrumb", ["properties", None])[1] for item in metadata
- if item.get("metadata").get("inclusion") == "automatic"
+ if item.get("metadata").get("inclusion") == "automatic"
)
actual_fields = []
for md_entry in metadata:
@@ -99,26 +357,41 @@ def test_run(self):
"\nstream_properties | {}".format(stream_properties))
# verify there are no duplicate metadata entries
- self.assertEqual(len(actual_fields), len(set(actual_fields)), msg = f"duplicates in the fields retrieved")
+ #self.assertEqual(len(actual_fields), len(set(actual_fields)), msg = f"duplicates in the fields retrieved")
+
+ # BUG_3 | primary keys have '.' for all core streams
# verify primary key(s)
- self.assertSetEqual(expected_primary_keys, actual_primary_keys, msg = f"expected primary keys is {expected_primary_keys} but actual primary keys is {actual_primary_keys}")
+ # self.assertSetEqual(expected_primary_keys, actual_primary_keys) # TODO POST IN SLACK
- # verify replication method TODO
+ # BUG_1' | all core streams are missing this metadata TODO does this thing even get used ANYWHERE?
+ # verify replication method
+ # self.assertEqual(expected_replication_method, actual_replication_method)
# verify replication key(s)
- self.assertEqual(expected_replication_keys, actual_replication_keys, msg = f"expected replication key is {expected_replication_keys} but actual replication key is {actual_replication_keys}")
+ self.assertSetEqual(expected_replication_keys, actual_replication_keys)
- # verify replication key is present for any stream with replication method = INCREMENTAL
+ # TODO | implement when foreign keys are complete
+ # verify foreign keys are present for each core stream
+ # self.assertSetEqual(expected_foreign_keys, actual_foreign_keys)
+
+ # verify foreign keys are given inclusion of automatic
+
+ # verify replication key is present for any stream with replication method = INCREMENTAL
if actual_replication_method == 'INCREMENTAL':
- self.assertEqual(expected_replication_keys, actual_replication_keys)
+ # TODO | Implement at time sync is working
+ # self.assertEqual(expected_replication_keys, actual_replication_keys)
+ pass
else:
- self.assertEqual(actual_replication_keys,set())
+ self.assertEqual(actual_replication_keys, set())
+
+ # verify all expected fields are found # TODO set expectations
+ # self.assertSetEqual(expected_fields, set(actual_fields))
# verify the stream is given the inclusion of available
- self.assertEqual(catalog['metadata']['inclusion'],'available', msg=f"{stream} cannot be selected")
+ self.assertEqual(catalog['metadata']['inclusion'], 'available', msg=f"{stream} cannot be selected")
# verify the primary, replication keys are given the inclusions of automatic
- self.assertSetEqual(expected_automatic_fields ,actual_automatic_fields)
+ #self.assertSetEqual(expected_automatic_fields, actual_automatic_fields)
# verify all other fields are given inclusion of available
self.assertTrue(
@@ -128,3 +401,6 @@ def test_run(self):
and item.get("breadcrumb", ["properties", None])[1]
not in actual_automatic_fields}),
msg="Not all non key properties are set to available in metadata")
+
+ # verify field exclusions for each strema match our expectations
+ # TODO further tests may be needed, including attempted syncs with invalid field combos
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
new file mode 100644
index 0000000..004dbd9
--- /dev/null
+++ b/tests/test_google_ads_sync_canary.py
@@ -0,0 +1,78 @@
+"""Test tap discovery mode and metadata."""
+import re
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class DiscoveryTest(GoogleAdsBase):
+ """Test tap discovery mode and metadata conforms to standards."""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_canary"
+
+ def test_run(self):
+ """
+ Testing that basic sync functions without Critical Errors
+ """
+ print("Discovery Test for tap-google-ads")
+
+ conn_id = connections.ensure_connection(self)
+
+ streams_to_test = self.expected_streams() - {
+ # TODO we are only testing core strems at the moment
+ 'landing_page_report',
+ 'expanded_landing_page_report',
+ 'display_topics_performance_report',
+ 'call_metrics_call_details_report',
+ 'gender_performance_report',
+ 'search_query_performance_report',
+ 'placeholder_feed_item_report',
+ 'keywords_performance_report',
+ 'video_performance_report',
+ 'campaign_performance_report',
+ 'geo_performance_report',
+ 'placeholder_report',
+ 'placement_performance_report',
+ 'click_performance_report',
+ 'display_keyword_performance_report',
+ 'shopping_performance_report',
+ 'ad_performance_report',
+ 'age_range_performance_report',
+ 'keywordless_query_report',
+ 'account_performance_report',
+ 'adgroup_performance_report',
+ 'audience_performance_report',
+ }
+
+ # Run a discovery job
+ check_job_name = runner.run_check_mode(self, conn_id)
+ exit_status = menagerie.get_exit_status(conn_id, check_job_name)
+ menagerie.verify_check_exit_status(self, exit_status, check_job_name)
+
+ # Verify a catalog was produced for each stream under test
+ found_catalogs = menagerie.get_catalogs(conn_id)
+ self.assertGreater(len(found_catalogs), 0)
+ found_catalog_names = {found_catalog['stream_name'] for found_catalog in found_catalogs}
+ self.assertSetEqual(streams_to_test, found_catalog_names)
+
+ # Perform table and field selection
+ self.select_all_streams_and_fields(conn_id, found_catalogs, select_all_fields=True)
+
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ # Verify the tap and target do not throw a critical error
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # acquire records from target output
+ synced_records = runner.get_records_from_target_output()
+
+ # Verify at least 1 record was replicated for each stream
+ for stream in streams_to_test:
+ record_count = len(synced_records[stream]['messages'])
+
+ self.assertGreater(record_count, 0)
From 5a09feac29dccc93d7d348e345c09f4f5eb8c150 Mon Sep 17 00:00:00 2001
From: kspeer
Date: Mon, 31 Jan 2022 17:46:48 +0000
Subject: [PATCH 18/69] Mark pk bug in tests TDL_17533
---
tests/test_google_ads_discovery.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index d74db61..2f7e81a 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -359,9 +359,10 @@ def test_run(self):
# verify there are no duplicate metadata entries
#self.assertEqual(len(actual_fields), len(set(actual_fields)), msg = f"duplicates in the fields retrieved")
- # BUG_3 | primary keys have '.' for all core streams
+ # BUG_TDL_17533
+ # [tap-google-ads] Primary keys have incorrect name for core objects
# verify primary key(s)
- # self.assertSetEqual(expected_primary_keys, actual_primary_keys) # TODO POST IN SLACK
+ # self.assertSetEqual(expected_primary_keys, actual_primary_keys) # BUG_TDL_17533
# BUG_1' | all core streams are missing this metadata TODO does this thing even get used ANYWHERE?
# verify replication method
From 76d06740d078d8f41304e447b96264f4e520309a Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Mon, 31 Jan 2022 14:04:17 -0500
Subject: [PATCH 19/69] Qa/start date (#12)
* updates to base
* tap_stream_id != stream_name BUG
* added start date test (WIP)
Co-authored-by: kspeer
---
tests/base.py | 15 ++-
tests/test_google_ads_discovery.py | 10 +-
tests/test_google_ads_start_date.py | 189 ++++++++++++++++++++++++++++
3 files changed, 209 insertions(+), 5 deletions(-)
create mode 100644 tests/test_google_ads_start_date.py
diff --git a/tests/base.py b/tests/base.py
index 3ded80a..59708bd 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -41,8 +41,9 @@ def get_type():
"""the expected url route ending"""
return "platform.google-ads"
- def get_properties(self):
- return {
+ def get_properties(self, original: bool = True):
+ """Configurable properties, with a switch to override the 'start_date' property"""
+ return_value = {
'start_date': '2020-12-01T00:00:00Z',
'user_id': 'not used?',
'customer_ids': '5548074409,2728292456',
@@ -58,6 +59,12 @@ def get_properties(self):
],
}
+ if original:
+ return return_value
+
+ return_value["start_date"] = self.start_date
+ return return_value
+
def get_credentials(self):
return {'developer_token': os.getenv('TAP_GOOGLE_ADS_DEVELOPER_TOKEN'),
'oauth_client_id': os.getenv('TAP_GOOGLE_ADS_OAUTH_CLIENT_ID'),
@@ -491,6 +498,7 @@ def parse_date(date_value):
raise NotImplementedError("Tests do not account for dates of this format: {}".format(date_value))
def timedelta_formatted(self, dtime, days=0):
+ """Convert a string formatted datetime to a new string formatted datetime with a timedelta applied."""
try:
date_stripped = dt.strptime(dtime, self.START_DATE_FORMAT)
return_date = date_stripped + timedelta(days=days)
@@ -511,6 +519,9 @@ def timedelta_formatted(self, dtime, days=0):
### Tap Specific Methods
##########################################################################
+ def is_report(self, stream):
+ return stream.endswith('_report')
+
# TODO exclusion rules
# TODO core objects vs reports
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index 2f7e81a..3e8f531 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -13,14 +13,14 @@ def expected_fields(self):
"""The expected streams and metadata about the streams"""
# TODO verify accounts, ads, ad_groups, campaigns contain foreign keys for
# 'campaign_budgets', 'bidding_strategies', 'accessible_bidding_strategies'
- # and only foreign keys BUT CHECK DOCS
+ # and only foreign keys BUT CHECK DOCS
return {
# Core Objects
"accounts": { # TODO check with Brian on changes
# OLD FIELDS (with mapping)
"currency_code",
- "id", # "customer_id",
+ "id", # "customer_id",
"manager", # "can_manage_clients",
"resource_name", # name -- unclear if this actually the mapping
"test_account",
@@ -262,7 +262,7 @@ def test_run(self):
conn_id = connections.ensure_connection(self)
streams_to_test = self.expected_streams() - {
- # BUG_2 | missing
+ # BUG_2 | missing
'landing_page_report',
'expanded_landing_page_report',
'display_topics_performance_report',
@@ -359,6 +359,10 @@ def test_run(self):
# verify there are no duplicate metadata entries
#self.assertEqual(len(actual_fields), len(set(actual_fields)), msg = f"duplicates in the fields retrieved")
+ # TODO BUG (unclear on significance in saas tap ?)
+ # verify the tap_stream_id and stream_name are consistent (only applies to SaaS taps)
+ # self.assertEqual(stream_properties[0]['stream_name'], stream_properties[0]['tap_stream_id'])
+
# BUG_TDL_17533
# [tap-google-ads] Primary keys have incorrect name for core objects
# verify primary key(s)
diff --git a/tests/test_google_ads_start_date.py b/tests/test_google_ads_start_date.py
new file mode 100644
index 0000000..7025df7
--- /dev/null
+++ b/tests/test_google_ads_start_date.py
@@ -0,0 +1,189 @@
+import os
+
+from tap_tester import connections, runner, menagerie
+
+from base import GoogleAdsBase
+
+
+class StartDateTest(GoogleAdsBase):
+
+ start_date_1 = ""
+ start_date_2 = ""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_start_date"
+
+ def test_run(self):
+ """Instantiate start date according to the desired data set and run the test"""
+
+ self.start_date_1 = self.get_properties().get('start_date') # '2020-12-01T00:00:00Z',
+ self.start_date_2 = self.timedelta_formatted(self.start_date_1, days=15)
+
+ self.start_date = self.start_date_1
+
+ streams_to_test = self.expected_streams() - {
+ # TODO we are only testing core strems at the moment
+ 'landing_page_report',
+ 'expanded_landing_page_report',
+ 'display_topics_performance_report',
+ 'call_metrics_call_details_report',
+ 'gender_performance_report',
+ 'search_query_performance_report',
+ 'placeholder_feed_item_report',
+ 'keywords_performance_report',
+ 'video_performance_report',
+ 'campaign_performance_report',
+ 'geo_performance_report',
+ 'placeholder_report',
+ 'placement_performance_report',
+ 'click_performance_report',
+ 'display_keyword_performance_report',
+ 'shopping_performance_report',
+ 'ad_performance_report',
+ 'age_range_performance_report',
+ 'keywordless_query_report',
+ 'account_performance_report',
+ 'adgroup_performance_report',
+ 'audience_performance_report',
+ }
+
+ ##########################################################################
+ ### Sync with Connection 1
+ ##########################################################################
+
+ # instantiate connection
+ conn_id_1 = connections.ensure_connection(self)
+
+ # run check mode
+ check_job_name_1 = runner.run_check_mode(self, conn_id_1) # TODO REMOVE START
+ exit_status_1 = menagerie.get_exit_status(conn_id_1, check_job_name_1)
+ menagerie.verify_check_exit_status(self, exit_status_1, check_job_name_1)
+ found_catalogs_1 = menagerie.get_catalogs(conn_id_1) # TODO REMOVE END
+ # found_catalogs_1 = self.run_and_verify_check_mode(conn_id_1) # TODO PUT BACK
+
+ # table and field selection
+ test_catalogs_1 = [catalog for catalog in found_catalogs_1
+ if catalog.get('stream_name') in streams_to_test]
+ self.select_all_streams_and_fields(conn_id_1, test_catalogs_1, select_all_fields=True) # TODO REMOVE
+ # self.perform_and_verify_table_and_field_selection(conn_id_1, test_catalogs_1, select_all_fields=True) # TODO PUT BACK
+
+ # run initial sync
+ sync_job_name_1 = runner.run_sync_mode(self, conn_id_1) # TODO REMOVE START
+ exit_status_1 = menagerie.get_exit_status(conn_id_1, sync_job_name_1)
+ menagerie.verify_sync_exit_status(self, exit_status_1, sync_job_name_1)
+ # BUG_TDL-17535 [tap-google-ads] Primary keys do not persist to target
+ # record_count_by_stream_1 = runner.examine_target_output_file(
+ # self, conn_id_1, streams_to_test, self.expected_primary_keys()) # BUG_TDL-17535
+ # TODO REMOVE END
+ # record_count_by_stream_1 = self.run_and_verify_sync(conn_id_1) # TODO PUT BACK
+ synced_records_1 = runner.get_records_from_target_output()
+
+ ##########################################################################
+ ### Update START DATE Between Syncs
+ ##########################################################################
+
+ print("REPLICATION START DATE CHANGE: {} ===>>> {} ".format(self.start_date, self.start_date_2))
+ self.start_date = self.start_date_2
+
+ ##########################################################################
+ ### Sync With Connection 2
+ ##########################################################################
+
+ # create a new connection with the new start_date
+ conn_id_2 = connections.ensure_connection(self, original_properties=False)
+
+ # run check mode
+ check_job_name_2 = runner.run_check_mode(self, conn_id_2) # TODO REMOVE START
+ exit_status_2 = menagerie.get_exit_status(conn_id_2, check_job_name_2)
+ menagerie.verify_check_exit_status(self, exit_status_2, check_job_name_2)
+ found_catalogs_2 = menagerie.get_catalogs(conn_id_2) # TODO REMOVE END
+ # found_catalogs_2 = self.run_and_verify_check_mode(conn_id_2) # TODO PUT BACK
+
+
+ # table and field selection
+ test_catalogs_2 = [catalog for catalog in found_catalogs_2
+ if catalog.get('stream_name') in streams_to_test]
+ self.select_all_streams_and_fields(conn_id_2, test_catalogs_2, select_all_fields=True) # TODO REMOVE
+ # self.perform_and_verify_table_and_field_selection(conn_id_2, test_catalogs_2, select_all_fields=True) # TODO PUT BACK
+
+
+ # run sync
+ sync_job_name_2 = runner.run_sync_mode(self, conn_id_2) # TODO REMOVE START
+ exit_status_2 = menagerie.get_exit_status(conn_id_2, sync_job_name_2)
+ menagerie.verify_sync_exit_status(self, exit_status_2, sync_job_name_2)
+ # BUG_TDL-17535 [tap-google-ads] Primary keys do not persist to target
+ # record_count_by_stream_2 = runner.examine_target_output_file(
+ # self, conn_id_2, streams_to_test, self.expected_primary_keys()) # BUG_TDL-17535
+ # TODO REMOVE END
+ # record_count_by_stream_2 = self.run_and_verify_sync(conn_id_2) # TODO PUT BACK
+ synced_records_2 = runner.get_records_from_target_output()
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ # expected values
+ # expected_primary_keys = self.expected_primary_keys()[stream] # BUG_TDL_17533
+ expected_primary_keys = {f'{stream}.id'} # BUG_TDL_17533
+
+ # TODO update this with the lookback window DOES IT APPLY TO START DATE?
+ # expected_conversion_window = -1 * int(self.get_properties()['conversion_window'])
+ expected_start_date_1 = self.timedelta_formatted(self.start_date_1, days=0) # expected_conversion_window)
+ expected_start_date_2 = self.timedelta_formatted(self.start_date_2, days=0) # expected_conversion_window)
+
+ # collect information for assertions from syncs 1 & 2 base on expected values
+ # record_count_sync_1 = record_count_by_stream_1.get(stream, 0) # BUG_TDL-17535
+ # record_count_sync_2 = record_count_by_stream_2.get(stream, 0) # BUG_TDL-17535
+ primary_keys_list_1 = [tuple(message.get('data').get(expected_pk) for expected_pk in expected_primary_keys)
+ for message in synced_records_1.get(stream).get('messages')
+ if message.get('action') == 'upsert']
+ primary_keys_list_2 = [tuple(message.get('data').get(expected_pk) for expected_pk in expected_primary_keys)
+ for message in synced_records_2.get(stream).get('messages')
+ if message.get('action') == 'upsert']
+ primary_keys_sync_1 = set(primary_keys_list_1)
+ primary_keys_sync_2 = set(primary_keys_list_2)
+
+ if self.is_report(stream):
+ # TODO IMPLEMENT WHEN REPORTS SYNC READY
+ # # collect information specific to incremental streams from syncs 1 & 2
+ # expected_replication_key = next(iter(self.expected_replication_keys().get(stream)))
+ # replication_dates_1 =[row.get('data').get(expected_replication_key) for row in
+ # synced_records_1.get(stream, {'messages': []}).get('messages', [])
+ # if row.get('data')]
+ # replication_dates_2 =[row.get('data').get(expected_replication_key) for row in
+ # synced_records_2.get(stream, {'messages': []}).get('messages', [])
+ # if row.get('data')]
+
+ # # # Verify replication key is greater or equal to start_date for sync 1
+ # for replication_date in replication_dates_1:
+ # self.assertGreaterEqual(
+ # self.parse_date(replication_date), self.parse_date(expected_start_date_1),
+ # msg="Report pertains to a date prior to our start date.\n" +
+ # "Sync start_date: {}\n".format(expected_start_date_1) +
+ # "Record date: {} ".format(replication_date)
+ # )
+
+ # # Verify replication key is greater or equal to start_date for sync 2
+ # for replication_date in replication_dates_2:
+ # self.assertGreaterEqual(
+ # self.parse_date(replication_date), self.parse_date(expected_start_date_2),
+ # msg="Report pertains to a date prior to our start date.\n" +
+ # "Sync start_date: {}\n".format(expected_start_date_2) +
+ # "Record date: {} ".format(replication_date)
+ # )
+
+ # # Verify the number of records replicated in sync 1 is greater than the number
+ # # of records replicated in sync 2
+ # self.assertGreater(record_count_sync_1, record_count_sync_2)
+
+ # # Verify the records replicated in sync 2 were also replicated in sync 1
+ # self.assertTrue(primary_keys_sync_2.issubset(primary_keys_sync_1))
+ pass
+ else:
+
+ # Verify that the 2nd sync with a later start date (more recent) replicates
+ # the same number of records as the 1st sync.
+ # self.assertEqual(record_count_sync_2, record_count_sync_1) # BUG_TDL-17535
+
+ # Verify by primary key the same records are replicated in the 1st and 2nd syncs
+ self.assertSetEqual(primary_keys_sync_1, primary_keys_sync_2)
From 23a045d2042ce24d3dcdc1981f21ecbbbf070a16 Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Thu, 24 Feb 2022 15:23:02 -0500
Subject: [PATCH 20/69] Report sync and bugfixes (#13)
* Fix metrics field exclusion bug
* Enable report discovery and sync
* Remove the prefix from fields names in the resource schema and json schema
* Fix bug caused by shared references
* Use a tuple literal
* Add table-foreign-key-properties to stream metadata
* Add function to create nested JSON schema for core streams
* Add tests for `create_nested_resource_schema`
* Refactor function to allow reuse with reports
* Fix tests after refactor
* Working sync for core streams
* Fix field exclusion and metadata bugs
* Add ReportStream class, update PK names
* Remove flatten because schema is no longer flat
* Fix primary key bug
* Require date as a segment
* Fix ads stream bugs
* Add remapping for ads in report
* WIP Incremental sync for report streams
* Make state global
* Add hash of attributes and segments as PK for report streams
* Change reports to use the ReportStream class
* Change adgroupperformancereport to use report stream, don't write state as
often
* Make pylint happy
* Change fields-to-sync to tap-google-ads.api-field-names
Co-authored-by: leslie vandemark lvandemark@stitchdata.com
* Fix record hash and add tests for record hash
* Make inclusion automatic on foreign keys
* Add docstring to create_resource_schema
* Change metadate fields_to_sync to tap-google-ads.api-field-names, don't
look for api-field-names on record hash
* Add conversion window, Don't store the whole response in memory since
paging happens automatically
* Enable `campaign_criteria_report`
* Fix field exclusion bug where one resource has the other in its
`selectable_with` list but not the other way around
* Change multiple resource reports to use one resource for alpha
* Change core sync to not load whole response in memory
* Change query range to 1 day, Add logs, begin refactoring
* Separate functionality into separate files
* Remove unused functions and variables
* WIP discovery refactor
* Move Required Config to init
* remove reports.py after rename
* Remove unused import
* Qa/reports 1 (#14)
* create spikes folder for discovery and add protobuff to setup
* add more fields to campaign schema
* Attempt at getting some kind of auto schema
* Clean up, added comments to links where to look up missing classes, refactored out giant elif blocks
* use refs
* Add note to inspect for enum types
* WIP on handling enums
* updated disco test
* wip syncing report streams
* Manger Segment BUG squashed
* wip on canary
* updated discovery and documented discrepancies within test
* auto fields bug mostly squarshed
* WIP canarying all streams
* sync canary passing with partial coverage
* Updates to dicovery, start date, and canary
* start date test wip
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: Dan Mosora <30501696+dmosorast@users.noreply.github.com>
Co-authored-by: Bryant Gray
Co-authored-by: kspeer
* Use the pylint disable env var (#15)
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
* Change single quotes to double quotes
* Remove `format_field_names`
We made this change to get a simple set of unit tests passing before we
refactor all of this discovery code. There seems to be a bug in the
current implementation of `format_field_names`
* Ensure segmenting resources are accounted for in metadata
* Pulled `format_field_names` into ReportStream class
* Pulled `format_field_names` into BaseStream class
* Refactor WIP; complete format_field_name moves in both base and report classes
* Refactor WIP; move metadata creation to base and report classes
* Refactor WIP: Complete discovery refactoring
* Make pylint as happy as possible knowing it will never be pleased
* First performance report update
* Remove extra category assignment
We ran tests locally and found that every `field_schema` has a category of
segment already. And because we weren't setting the category for
attributes and metrics as well, this code is just redundant.
* disco test update, auto fields test added
* Ran black
* Add user_view_performance_report
* Update config with parallelized jobs and steps
* trust the workspace attach to persist a file
* just copy env vars where needed
* testing a test counter
* Remove multiple-resource related code (#16)
* Use `singer.utils.should_sync_field` to filter for selected fields
* Add the user_view_performance_report to tests
* Write the json schema for date fields as datetimes
* Compare datetime objects instead of strings (#17)
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
* updates to auto, disoc, start tests
* Add workarounds to get tests passing
* Sort excluded streams
* Update user_view to user_location_performation_report
* auto field test passing with workaround
* Adds failing tests for metadata bugs.
Add failing tests for TDL-17848 and TDL-17845
* Fix failing metadata tests
Adds forced replication method and valid replication key.
* Ensure transformer filters out non-selected fields
* Update test expectations to allow null foreign keys
* tests partial refactor for selecting streams/fields, add first report to bookmarks test
* Update reports test to check each report for automatic fields
* Raise exception when report only has automatic fields selected
* Clean up imports
* increased test coverage for start date
* updates to auto fields test, simplify field selection in start date
* fix automatic fields for core stream foreign keys
* WIP start date test split out streams to test
* Finalized start date test for Alpha
* fix the parallelism 9->6
* just install required dependencies
* don't count test
* fix pylint with variable reference
* Install pylint
* Show the tap installed correctly
* Revert "Show the tap installed correctly"
This reverts commit 5b066f4acd97c228278417bde5ab91a6f46a21e8.
* Revert "Install pylint"
This reverts commit 06d196920b789fd50a67226b4c003cc4670dcecf.
* Install tap-google-ads without `-e`, install dev dependencies
* Bugs squashed: 17839, 17827
* Bug squashed: TDL-17840
* Failing assertions for TDL-17887
* Regroup functions for readability, rename sync_* to sync, set currently
syncing in core streams
* Revert to conversion_window per discussed naming conventions
* Clear currently_syncing after stream completion
* Fix bug TDL-17887, whitespace clean up
* Fix import for create_nested_resource_schema
* Remove tests for deleted functions
* Update import for generate_hash
* Add get_query_date, add unit tests for get_query_date
* Add unit tests to circle runs
* Finish refactor - call the new function name
* Fix imports in unit test
* Fix bug in refactor: update arg name
* Remove default value in get_bookmark
* Pass in a datetime string to get_query_date
* Return datetime objects from get_query_date
* Clean up TODO
* Fix unit tests
* Bookmark updates, test more streams, remove lookback from start_date
* minor cleanup bookmarks test
* Delete campaigns.json
* Delete schema_gen_protobuf.py
* Test Cleanup
* comments
* fix typo in disco test
* fix typo in discovery test
Co-authored-by: Bryant Gray
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: Andy Lu
Co-authored-by: Dan Mosora <30501696+dmosorast@users.noreply.github.com>
Co-authored-by: kspeer
Co-authored-by: btowles
Co-authored-by: Andy Lu
---
.circleci/config.yml | 118 +-
spikes/schema_gen_protobuf.py | 159 --
tap_google_ads/__init__.py | 420 +----
tap_google_ads/client.py | 17 +
tap_google_ads/discover.py | 254 +++
tap_google_ads/report_definitions.py | 1946 +++++++++++++++++++--
tap_google_ads/reports.py | 445 -----
tap_google_ads/streams.py | 620 +++++++
tap_google_ads/sync.py | 45 +
tests/base.py | 315 +++-
tests/test_google_ads_automatic_fields.py | 121 ++
tests/test_google_ads_bookmarks.py | 195 ++-
tests/test_google_ads_discovery.py | 156 +-
tests/test_google_ads_start_date.py | 221 ++-
tests/test_google_ads_start_date_2.py | 22 +
tests/test_google_ads_sync_canary.py | 359 +++-
tests/unittests/test_resource_schema.py | 4 +-
tests/unittests/test_utils.py | 228 ++-
18 files changed, 4122 insertions(+), 1523 deletions(-)
delete mode 100644 spikes/schema_gen_protobuf.py
create mode 100644 tap_google_ads/client.py
create mode 100644 tap_google_ads/discover.py
delete mode 100644 tap_google_ads/reports.py
create mode 100644 tap_google_ads/streams.py
create mode 100644 tap_google_ads/sync.py
create mode 100644 tests/test_google_ads_automatic_fields.py
create mode 100644 tests/test_google_ads_start_date_2.py
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 988bb78..de55bc9 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,50 +2,123 @@ version: 2.1
orbs:
slack: circleci/slack@3.4.2
-jobs:
- build:
+executors:
+ docker-executor:
docker:
- image: 218546966473.dkr.ecr.us-east-1.amazonaws.com/circle-ci:stitch-tap-tester
+
+jobs:
+ # TODO remove if not needed
+ # build:
+ # executor: docker-executor
+ # steps:
+ # - run: echo 'CI done'
+ ensure_env:
+ executor: docker-executor
steps:
- checkout
- run:
name: 'Setup virtual env'
command: |
- aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
python3 -mvenv /usr/local/share/virtualenvs/tap-google-ads
source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- pip install -U pip setuptools
- pip install -e .[dev]
+ pip install 'pip==21.1.3'
+ pip install 'setuptools==56.0.0'
+ pip install .[dev]
+ - slack/notify-on-failure:
+ only_for_branches: master
+ - persist_to_workspace:
+ root: /usr/local/share/virtualenvs
+ paths:
+ - tap-google-ads
+ run_pylint:
+ executor: docker-executor
+ steps:
+ - checkout
+ - attach_workspace:
+ at: /usr/local/share/virtualenvs
+ - run:
+ name: 'Run pylint'
+ command: |
+ source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
+ aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
+ source dev_env.sh
+ echo "$PYLINT_DISABLE_LIST"
+ pylint tap_google_ads --disable "$PYLINT_DISABLE_LIST"
+ - slack/notify-on-failure:
+ only_for_branches: master
+
+ run_unit_tests:
+ executor: docker-executor
+ steps:
+ - checkout
+ - attach_workspace:
+ at: /usr/local/share/virtualenvs
- run:
- name: 'pylint'
+ name: 'Run Unit Tests'
command: |
source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- # TODO: Adjust the pylint disables
- pylint tap_google_ads --disable 'broad-except,chained-comparison,empty-docstring,fixme,invalid-name,line-too-long,missing-class-docstring,missing-function-docstring,missing-module-docstring,no-else-raise,no-else-return,too-few-public-methods,too-many-arguments,too-many-branches,too-many-lines,too-many-locals,ungrouped-imports,wrong-spelling-in-comment,wrong-spelling-in-docstring,bad-whitespace,missing-class-docstring'
- # TODO implement this run block when tests are avialable!
- # - run:
- # name: 'Unit Tests'
- # command: |
- # source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- # nosetests tests/unittests
+ pip install nose coverage
+ nosetests --with-coverage --cover-erase --cover-package=tap_google_ads --cover-html-dir=htmlcov tests/unittests
+ coverage html
+ - store_test_results:
+ path: test_output/report.xml
+ - store_artifacts:
+ path: htmlcov
+ - slack/notify-on-failure:
+ only_for_branches: master
+
+ run_integration_tests:
+ executor: docker-executor
+ parallelism: 6
+ steps:
+ - checkout
+ - attach_workspace:
+ at: /usr/local/share/virtualenvs
- run:
- name: 'Integration Tests'
+ name: 'Run Integration Tests'
command: |
+ aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
source dev_env.sh
source /usr/local/share/virtualenvs/tap-tester/bin/activate
- run-test --tap=tap-google-ads tests
+ circleci tests glob "tests/*.py" | circleci tests split > ./tests-to-run
+ if [ -s ./tests-to-run ]; then
+ for test_file in $(cat ./tests-to-run)
+ do
+ run-test --tap=${CIRCLE_PROJECT_REPONAME} $test_file
+ done
+ fi
- slack/notify-on-failure:
- only_for_branches: main
+ only_for_branches: master
workflows:
version: 2
- commit:
+ commit: &commit_jobs
jobs:
- - build:
+ - ensure_env:
+ context:
+ - circleci-user
+ - tier-1-tap-user
+ - run_pylint:
context:
- circleci-user
- - tap-tester-user
+ - tier-1-tap-user
+ requires:
+ - ensure_env
+ - run_unit_tests:
+ context:
+ - circleci-user
+ - tier-1-tap-user
+ requires:
+ - ensure_env
+ - run_integration_tests:
+ context:
+ - circleci-user
+ - tier-1-tap-user
+ requires:
+ - ensure_env
build_daily:
+ <<: *commit_jobs
triggers:
- schedule:
cron: "0 3 * * *"
@@ -53,8 +126,3 @@ workflows:
branches:
only:
- main
- jobs:
- - build:
- context:
- - circleci-user
- - tap-tester-user
diff --git a/spikes/schema_gen_protobuf.py b/spikes/schema_gen_protobuf.py
deleted file mode 100644
index 0e076cc..0000000
--- a/spikes/schema_gen_protobuf.py
+++ /dev/null
@@ -1,159 +0,0 @@
-import importlib
-import json
-import os
-import pkgutil
-import re
-from google.ads.googleads.v9.resources.types import campaign, ad, ad_group, customer
-from google.protobuf.pyext.cpp_message import GeneratedProtocolMessageType
-from google.protobuf.pyext._message import RepeatedScalarContainer, RepeatedCompositeContainer
-
-#>>> type(campaign.Campaign()._pb.target_spend.__class__)
-#
-
-# Unknown types lookup to their actual class in the code. For some reason these differ.
-# Unknown classes should be found in this project, likely somehwere around here:
-# https://github.com/googleads/google-ads-python/tree/14.1.0/google/ads/googleads/v9/common/types
-type_lookup = {"google.ads.googleads.v9.common.FinalAppUrl": "google.ads.googleads.v9.common.types.final_app_url.FinalAppUrl",
- "google.ads.googleads.v9.common.AdVideoAsset": "google.ads.googleads.v9.common.types.ad_asset.AdVideoAsset",
- "google.ads.googleads.v9.common.AdTextAsset": "google.ads.googleads.v9.common.types.ad_asset.AdTextAsset",
- "google.ads.googleads.v9.common.AdMediaBundleAsset": "google.ads.googleads.v9.common.types.ad_asset.AdMediaBundleAsset",
- "google.ads.googleads.v9.common.AdImageAsset": "google.ads.googleads.v9.common.types.ad_asset.AdImageAsset",
-
- "google.ads.googleads.v9.common.PolicyTopicEntry": "google.ads.googleads.v9.common.types.policy.PolicyTopicEntry",
- "google.ads.googleads.v9.common.PolicyTopicConstraint": "google.ads.googleads.v9.common.types.policy.PolicyTopicConstraint",
- "google.ads.googleads.v9.common.PolicyTopicEvidence": "google.ads.googleads.v9.common.types.policy.PolicyTopicEvidence",
- "google.ads.googleads.v9.common.PolicyTopicConstraint.CountryConstraint": "google.ads.googleads.v9.common.types.policy.PolicyTopicConstraint", # This one's weird, handling it manually in the generator
-
- "google.ads.googleads.v9.common.UrlCollection": "google.ads.googleads.v9.common.types.url_collection.UrlCollection",
-
- "google.ads.googleads.v9.common.CustomParameter": "google.ads.googleads.v9.common.types.custom_parameter.CustomParameter",
-
- "google.ads.googleads.v9.common.ProductImage": "google.ads.googleads.v9.common.types.ad_type_infos.ProductImage",
- "google.ads.googleads.v9.common.ProductVideo": "google.ads.googleads.v9.common.types.ad_type_infos.ProductVideo",
-
- "google.ads.googleads.v9.common.FrequencyCapEntry": "google.ads.googleads.v9.common.types.frequency_cap.FrequencyCapEntry",
-
- "google.ads.googleads.v9.common.TargetRestriction": "google.ads.googleads.v9.common.types.targeting_setting.TargetRestriction",
- "google.ads.googleads.v9.common.TargetRestrictionOperation": "google.ads.googleads.v9.common.types.targeting_setting.TargetRestrictionOperation",
- }
-
-# From: https://stackoverflow.com/questions/19053707/converting-snake-case-to-lower-camel-case-lowercamelcase
-def to_camel_case(snake_str):
- components = snake_str.split('_')
- # We capitalize the first letter of each component except the first one
- # with the 'title' method and join them together.
- return components[0] + ''.join(x.title() for x in components[1:])
-
-def type_to_json_schema(typ):
- # TODO: Bytes in an anyOf gives us, usually, just 'string', so it can be a non-anyOf?
- if typ == 'bytes':
- return {"type": ["null","UNSUPPORTED_string"]}
- elif typ == 'int':
- return {"type": ["null","integer"]}
- elif typ in ['str','unicode']:
- return {"type": ["null","string"]}
- elif typ == 'long':
- return {"type": ["null","integer"]}
- else:
- raise Exception(f"Unknown scalar type {typ}")
-
-def handle_scalar_container(acc, prop_val, prop_camel):
- try:
- prop_val.append(1)
- prop_val.append(True)
- prop_val.append(0.0)
- except TypeError as e:
- re_result = re.search(r"but expected one of: (.+)$", str(e))
- if re_result:
- actual_types = re_result.groups()[0].split(',')
- actual_types = [t.strip() for t in actual_types]
- acc[prop_camel] = {"type": ["null", "array"],
- "items": {"anyOf": [type_to_json_schema(t) for t in actual_types]}}
- else:
- raise
-
-ref_schema_lookup = {}
-def handle_composite_container(acc, prop_val, prop_camel):
- try:
- prop_val.append(1)
- prop_val.append(True)
- prop_val.append(0.0)
- except TypeError as e:
- re_result = re.search(r"expected (.+) got ", str(e))
- if not re_result:
- import ipdb; ipdb.set_trace()
- 1+1
- raise
- shown_type = re_result.groups()[0]
- actual_type = type_lookup.get(shown_type)
- if not actual_type:
- print(f"Unknown composite type: {shown_type}")
- else:
- # TODO: Should we just build the objects based on the type name and insert them as a definition and $ref to save space?
- mod = importlib.import_module('.'.join(actual_type.split('.')[:-1]))
- if shown_type == "google.ads.googleads.v9.common.PolicyTopicConstraint.CountryConstraint":
- obj = getattr(mod, actual_type.split('.')[-1]).CountryConstraint()
- else:
- obj = getattr(mod, actual_type.split('.')[-1])()
- type_name = shown_type.split('.')[-1]
- acc[prop_camel] = {"type": ["null", "array"],
- "items":{"$ref": f"#/definitions/{type_name}"}}
- if type_name not in ref_schema_lookup:
- ref_schema_lookup[type_name] = get_schema({},obj._pb)
-
-def get_schema(acc, current):
- for prop in filter(lambda p: re.search(r"^[a-z]", p), dir(current)):
- try:
- prop_val = getattr(current, prop)
- prop_camel = to_camel_case(prop)
- if isinstance(prop_val.__class__, GeneratedProtocolMessageType):
- # TODO: Should we just build the objects based on the type name and insert them as a definition and $ref to save space?
- new_acc_obj = {}
- type_name = type(prop_val).__qualname__
- acc[prop_camel] = {"$ref": f"#/definitions/{type_name}"}
- if type_name not in ref_schema_lookup:
- ref_schema_lookup[type_name] = {"type": ["null", "object"],
- "properties": get_schema(new_acc_obj, prop_val)}
- elif isinstance(prop_val, bool):
- acc[prop_camel] = {"type": ["null", "boolean"]}
- elif isinstance(prop_val, str):
- acc[prop_camel] = {"type": ["null", "string"]}
- elif isinstance(prop_val, int):
- acc[prop_camel] = {"type": ["null", "integer"]}
- elif isinstance(prop_val, float):
- acc[prop_camel] = {"type": ["null", "string"],
- "format": "singer.decimal"}
- elif isinstance(prop_val, bytes):
- # TODO: Should this just be empty? Then put it elsewhere to mark as unsupported? With a message?
- # - Or should we just make it string?
- acc[prop_camel] = {"type": ["null", "UNSUPPORTED_string"]}
- elif isinstance(prop_val, RepeatedScalarContainer):
- handle_scalar_container(acc, prop_val, prop_camel)
- elif isinstance(prop_val, RepeatedCompositeContainer):
- handle_composite_container(acc, prop_val, prop_camel)
- else:
- import ipdb; ipdb.set_trace()
- 1+1
- raise Exception(f"Unhandled type {type(prop_val)}")
- except Exception as e:
- raise
- #import ipdb; ipdb.set_trace()
- # 1+1
- return acc
-
-def root_get_schema(obj, pb):
- schema = get_schema(obj, pb)
- global ref_schema_lookup
- schema["definitions"] = ref_schema_lookup
- ref_schema_lookup = {}
- return schema
-
-with open("auto_campaign.json", "w") as f:
- json.dump(root_get_schema({}, campaign.Campaign()._pb), f)
-with open("auto_ad.json", "w") as f:
- json.dump(root_get_schema({}, ad.Ad()._pb), f)
-with open("auto_ad_group.json", "w") as f:
- json.dump(root_get_schema({}, ad_group.AdGroup()._pb), f)
-with open("auto_account.json", "w") as f:
- json.dump(root_get_schema({}, customer.Customer()._pb), f)
-print("Wrote schemas to local directory under auto_*.json, please review and manually set datetime formats on datetime fields and change Enum field types to 'string' schema.")
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 5775d5b..307a96d 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -1,23 +1,11 @@
#!/usr/bin/env python3
-import json
-import os
-import re
-import sys
-
import singer
from singer import utils
-from singer import bookmarks
-from singer import metadata
-from singer import transform, UNIX_MILLISECONDS_INTEGER_DATETIME_PARSING, Transformer
-
-from google.ads.googleads.client import GoogleAdsClient
-from google.ads.googleads.errors import GoogleAdsException
-from google.protobuf.json_format import MessageToJson
-from tap_google_ads.reports import initialize_core_streams
-from tap_google_ads.reports import initialize_reports
+from tap_google_ads.discover import create_resource_schema
+from tap_google_ads.discover import do_discover
+from tap_google_ads.sync import do_sync
-API_VERSION = "v9"
LOGGER = singer.get_logger()
@@ -30,402 +18,34 @@
"developer_token",
]
-CORE_ENDPOINT_MAPPINGS = {
- "campaign": {"primary_keys": ["id"], "stream_name": "campaigns"},
- "ad_group": {"primary_keys": ["id"], "stream_name": "ad_groups"},
- "ad_group_ad": {"primary_keys": ["id"], "stream_name": "ads"},
- "customer": {"primary_keys": ["id"], "stream_name": "accounts"},
-}
-
-REPORTS = [
- "accessible_bidding_strategy",
- "ad_group",
- "ad_group_ad",
- "ad_group_audience_view",
- "age_range_view",
- "bidding_strategy",
- "call_view",
- "campaign",
- "campaign_audience_view",
- "campaign_budget",
- "click_view",
- "customer",
- "display_keyword_view",
- "dynamic_search_ads_search_term_view",
- "expanded_landing_page_view",
- "feed_item",
- "feed_item_target",
- "feed_placeholder_view",
- "gender_view",
- "geographic_view",
- "keyword_view",
- "landing_page_view",
- "managed_placement_view",
- "search_term_view",
- "shopping_performance_view",
- "topic_view",
- "user_location_view",
- "video",
-]
-
-CATEGORY_MAP = {
- 0: "UNSPECIFIED",
- 1: "UNKNOWN",
- 2: "RESOURCE",
- 3: "ATTRIBUTE",
- 5: "SEGMENT",
- 6: "METRIC",
-}
-
-
-def get_attributes(api_objects, resource):
- resource_attributes = []
-
- if CATEGORY_MAP[resource.category] != "RESOURCE":
- # Attributes, segments, and metrics do not have attributes
- return resource_attributes
-
- attributed_resources = set(resource.attribute_resources)
- for field in api_objects:
- root_object_name = field.name.split(".")[0]
- does_field_exist_on_resource = (
- root_object_name == resource.name
- or root_object_name in attributed_resources
- )
- is_field_an_attribute = CATEGORY_MAP[field.category] == "ATTRIBUTE"
- if is_field_an_attribute and does_field_exist_on_resource:
- resource_attributes.append(field.name)
- return resource_attributes
-
-
-def get_segments(resource_schema, resource):
- resource_segments = []
-
- if resource["category"] != "RESOURCE":
- # Attributes, segments, and metrics do not have attributes
- return resource_segments
-
- segments = resource["segments"]
- for segment in segments:
- if segment.startswith("segments."):
- resource_segments.append(segment)
- else:
- segment_schema = resource_schema[segment]
- segment_attributes = [
- attribute
- for attribute in segment_schema["attributes"]
- if attribute.startswith(f"{segment}.")
- ]
- resource_segments.extend(segment_attributes)
- return resource_segments
-
-
-def create_resource_schema(config):
- client = GoogleAdsClient.load_from_dict(get_client_config(config))
- gaf_service = client.get_service("GoogleAdsFieldService")
-
- query = "SELECT name, category, data_type, selectable, filterable, sortable, selectable_with, metrics, segments, is_repeated, type_url, enum_values, attribute_resources"
-
- api_objects = gaf_service.search_google_ads_fields(query=query)
-
- # These are the data types returned from google. They are mapped to json schema. UNSPECIFIED and UNKNOWN have never been returned.
- # 0: "UNSPECIFIED", 1: "UNKNOWN", 2: "BOOLEAN", 3: "DATE", 4: "DOUBLE", 5: "ENUM", 6: "FLOAT", 7: "INT32", 8: "INT64", 9: "MESSAGE", 10: "RESOURCE_NAME", 11: "STRING", 12: "UINT64"
- data_type_map = {
- 0: {"type": ["null", "string"]},
- 1: {"type": ["null", "string"]},
- 2: {"type": ["null", "boolean"]},
- 3: {"type": ["null", "string"]},
- 4: {"type": ["null", "string"], "format": "singer.decimal"},
- 5: {"type": ["null", "string"]},
- 6: {"type": ["null", "string"], "format": "singer.decimal"},
- 7: {"type": ["null", "integer"]},
- 8: {"type": ["null", "integer"]},
- 9: {"type": ["null", "object", "string"], "properties": {}},
- 10: {"type": ["null", "object", "string"], "properties": {}},
- 11: {"type": ["null", "string"]},
- 12: {"type": ["null", "integer"]},
- }
-
- resource_schema = {}
-
- for resource in api_objects:
- attributes = get_attributes(api_objects, resource)
-
- resource_metadata = {
- "name": resource.name,
- "category": CATEGORY_MAP[resource.category],
- "json_schema": data_type_map[resource.data_type],
- "selectable": resource.selectable,
- "filterable": resource.filterable,
- "sortable": resource.sortable,
- "selectable_with": set(resource.selectable_with),
- "metrics": list(resource.metrics),
- "segments": list(resource.segments),
- "attributes": attributes,
- }
-
- resource_schema[resource.name] = resource_metadata
-
- for resource in resource_schema.values():
- updated_segments = get_segments(resource_schema, resource)
- resource["segments"] = updated_segments
-
- for report in REPORTS:
- report_object = resource_schema[report]
- fields = {}
- attributes = report_object["attributes"]
- metrics = report_object["metrics"]
- segments = report_object["segments"]
- for field in attributes + metrics + segments:
- field_schema = dict(resource_schema[field])
-
- if field_schema["name"] in segments:
- field_schema["category"] = "SEGMENT"
-
- fields[field_schema["name"]] = {
- "field_details": field_schema,
- "incompatible_fields": [],
- }
-
- metrics_and_segments = set(metrics + segments)
- for field_name, field in fields.items():
- if field["field_details"]["category"] == "ATTRIBUTE":
- continue
- for compared_field in metrics_and_segments:
-
- if not (
- field_name.startswith("segments.")
- or field_name.startswith("metrics.")
- ):
- field_root_resource = field_name.split(".")[0]
- else:
- field_root_resource = None
-
- if (field_name != compared_field) and (
- compared_field.startswith("metrics.")
- or compared_field.startswith("segments.")
- ):
- field_to_check = field_root_resource or field_name
- if (
- field_to_check
- not in resource_schema[compared_field]["selectable_with"]
- ):
- field["incompatible_fields"].append(compared_field)
-
- report_object["fields"] = fields
- return resource_schema
-
-
-def canonicalize_name(name):
- """Remove all dot and underscores and camel case the name."""
- tokens = re.split("\\.|_", name)
-
- first_word = [tokens[0]]
- other_words = [word.capitalize() for word in tokens[1:]]
-
- return "".join(first_word + other_words)
-
-
-def do_discover_core_streams(resource_schema):
- adwords_to_google_ads = initialize_core_streams(resource_schema)
-
- catalog = []
- for stream_name, stream in adwords_to_google_ads.items():
- resource_object = resource_schema[stream.google_ads_resources_name[0]]
- fields = resource_object["fields"]
- report_schema = {}
- report_metadata = {
- tuple(): {
- "inclusion": "available",
- "table-key-properties": stream.primary_keys,
- }
- }
-
- for field, props in fields.items():
- resource_matches = field.startswith(resource_object["name"] + ".")
- is_id_field = field.endswith(".id")
-
- if props["field_details"]["category"] == "ATTRIBUTE" and (
- resource_matches or is_id_field
- ):
- if resource_matches:
- field = ".".join(field.split(".")[1:])
- elif is_id_field:
- field = field.replace(".", "_")
-
- the_schema = props["field_details"]["json_schema"]
- report_schema[field] = the_schema
- report_metadata[("properties", field)] = {
- "fieldExclusions": props["incompatible_fields"],
- "behavior": props["field_details"]["category"],
- }
- if field in stream.primary_keys:
- inclusion = "automatic"
- elif props["field_details"]["selectable"]:
- inclusion = "available"
- else:
- inclusion = "unsupported"
- report_metadata[("properties", field)]["inclusion"] = inclusion
- catalog_entry = {
- "tap_stream_id": stream.google_ads_resources_name[0],
- "stream": stream_name,
- "schema": {
- "type": ["null", "object"],
- "properties": report_schema,
- },
- "metadata": singer.metadata.to_list(report_metadata),
- }
- catalog.append(catalog_entry)
- return catalog
-
-
-def create_field_metadata(primary_key, schema):
- mdata = {}
- mdata = metadata.write(mdata, (), "inclusion", "available")
- mdata = metadata.write(mdata, (), "table-key-properties", primary_key)
-
- for field in schema["properties"]:
- breadcrumb = ("properties", str(field))
- mdata = metadata.write(mdata, breadcrumb, "inclusion", "available")
-
- mdata = metadata.write(
- mdata, ("properties", primary_key[0]), "inclusion", "automatic"
- )
- mdata = metadata.to_list(mdata)
-
- return mdata
-
-
-def create_sdk_client(config, login_customer_id=None):
- CONFIG = {
- "use_proto_plus": False,
- "developer_token": config["developer_token"],
- "client_id": config["oauth_client_id"],
- "client_secret": config["oauth_client_secret"],
- # "access_token": config["access_token"], # BUG? REMOVE ME!
- "refresh_token": config["refresh_token"],
- }
-
- if login_customer_id:
- CONFIG["login_customer_id"] = login_customer_id
-
- sdk_client = GoogleAdsClient.load_from_dict(CONFIG)
- return sdk_client
-
-
-def do_sync(config, catalog, resource_schema):
- # QA ADDED WORKAROUND [START]
- try:
- customers = json.loads(config["login_customer_ids"])
- except TypeError: # falling back to raw value
- customers = config["login_customer_ids"]
- # QA ADDED WORKAROUND [END]
-
- selected_streams = [
- stream
- for stream in catalog["streams"]
- if singer.metadata.to_map(stream["metadata"])[()].get("selected")
- ]
-
- core_streams = initialize_core_streams(resource_schema)
-
- for customer in customers:
- sdk_client = create_sdk_client(config, customer["loginCustomerId"])
- for catalog_entry in selected_streams:
- stream_name = catalog_entry["stream"]
- if stream_name in core_streams:
- stream_obj = core_streams[stream_name]
-
- mdata_map = singer.metadata.to_map(catalog_entry["metadata"])
-
- primary_key = (
- mdata_map[()].get("metadata", {}).get("table-key-properties", [])
- )
- singer.messages.write_schema(
- stream_name, catalog_entry["schema"], primary_key
- )
- stream_obj.sync(sdk_client, customer, catalog_entry)
-
-
-def do_discover(resource_schema):
- core_streams = do_discover_core_streams(resource_schema)
- # report_streams = do_discover_reports(resource_schema)
- streams = []
- streams.extend(core_streams)
- # streams.extend(report_streams)
- json.dump({"streams": streams}, sys.stdout, indent=2)
-
-
-def do_discover_reports(resource_schema):
- ADWORDS_TO_GOOGLE_ADS = initialize_reports(resource_schema)
-
- streams = []
- for adwords_report_name, report in ADWORDS_TO_GOOGLE_ADS.items():
- report_mdata = {tuple(): {"inclusion": "available"}}
- try:
- for report_field in report.fields:
- # field = resource_schema[report_field]
- report_mdata[("properties", report_field)] = {
- # "fieldExclusions": report.field_exclusions.get(report_field, []),
- # "behavior": report.behavior.get(report_field, "ATTRIBUTE"),
- "fieldExclusions": report.field_exclusions[report_field],
- "behavior": report.behavior[report_field],
- }
-
- if report.behavior[report_field]:
- inclusion = "available"
- else:
- inclusion = "unsupported"
- report_mdata[("properties", report_field)]["inclusion"] = inclusion
- except Exception as err:
- print(f"Error in {adwords_report_name}")
- raise err
-
- catalog_entry = {
- "tap_stream_id": adwords_report_name,
- "stream": adwords_report_name,
- "schema": {
- "type": ["null", "object"],
- "is_report": True,
- "properties": report.schema,
- },
- "metadata": singer.metadata.to_list(report_mdata),
- }
- streams.append(catalog_entry)
-
- return streams
-
-
-def get_client_config(config, login_customer_id=None):
- client_config = {
- "use_proto_plus": False,
- "developer_token": config["developer_token"],
- "client_id": config["oauth_client_id"],
- "client_secret": config["oauth_client_secret"],
- "refresh_token": config["refresh_token"],
- # "access_token": config["access_token"], # BUG? REMOVE ME
- }
-
- if login_customer_id:
- client_config["login_customer_id"] = login_customer_id
-
- return client_config
-
-
-def main():
+def main_impl():
args = utils.parse_args(REQUIRED_CONFIG_KEYS)
-
resource_schema = create_resource_schema(args.config)
+ state = {}
+
+ if args.state:
+ state.update(args.state)
if args.discover:
do_discover(resource_schema)
LOGGER.info("Discovery complete")
elif args.catalog:
- do_sync(args.config, args.catalog.to_dict(), resource_schema)
+ do_sync(args.config, args.catalog.to_dict(), resource_schema, state)
LOGGER.info("Sync Completed")
else:
LOGGER.info("No properties were selected")
+def main():
+
+ try:
+ main_impl()
+ except Exception as e:
+ LOGGER.exception(e)
+ for line in str(e).splitlines():
+ LOGGER.critical(line)
+ raise e
+
+
if __name__ == "__main__":
main()
diff --git a/tap_google_ads/client.py b/tap_google_ads/client.py
new file mode 100644
index 0000000..666083e
--- /dev/null
+++ b/tap_google_ads/client.py
@@ -0,0 +1,17 @@
+from google.ads.googleads.client import GoogleAdsClient
+
+
+def create_sdk_client(config, login_customer_id=None):
+ CONFIG = {
+ "use_proto_plus": False,
+ "developer_token": config["developer_token"],
+ "client_id": config["oauth_client_id"],
+ "client_secret": config["oauth_client_secret"],
+ "refresh_token": config["refresh_token"],
+ }
+
+ if login_customer_id:
+ CONFIG["login_customer_id"] = login_customer_id
+
+ sdk_client = GoogleAdsClient.load_from_dict(CONFIG)
+ return sdk_client
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
new file mode 100644
index 0000000..04df284
--- /dev/null
+++ b/tap_google_ads/discover.py
@@ -0,0 +1,254 @@
+import json
+import sys
+
+import singer
+
+from tap_google_ads.client import create_sdk_client
+from tap_google_ads.streams import initialize_core_streams
+from tap_google_ads.streams import initialize_reports
+
+API_VERSION = "v9"
+
+LOGGER = singer.get_logger()
+
+REPORTS = [
+ "accessible_bidding_strategy",
+ "ad_group",
+ "ad_group_ad",
+ "ad_group_audience_view",
+ "age_range_view",
+ "bidding_strategy",
+ "call_view",
+ "campaign",
+ "campaign_audience_view",
+ "campaign_budget",
+ "campaign_criterion",
+ "click_view",
+ "customer",
+ "display_keyword_view",
+ "dynamic_search_ads_search_term_view",
+ "expanded_landing_page_view",
+ "feed_item",
+ "feed_item_target",
+ "feed_placeholder_view",
+ "gender_view",
+ "geographic_view",
+ "keyword_view",
+ "landing_page_view",
+ "managed_placement_view",
+ "search_term_view",
+ "shopping_performance_view",
+ "topic_view",
+ "user_location_view",
+ "video",
+]
+
+CATEGORY_MAP = {
+ 0: "UNSPECIFIED",
+ 1: "UNKNOWN",
+ 2: "RESOURCE",
+ 3: "ATTRIBUTE",
+ 5: "SEGMENT",
+ 6: "METRIC",
+}
+
+
+def get_api_objects(config):
+ client = create_sdk_client(config)
+ gaf_service = client.get_service("GoogleAdsFieldService")
+
+ query = "SELECT name, category, data_type, selectable, filterable, sortable, selectable_with, metrics, segments, is_repeated, type_url, enum_values, attribute_resources"
+
+ api_objects = gaf_service.search_google_ads_fields(query=query)
+ return api_objects
+
+
+def get_attributes(api_objects, resource):
+ resource_attributes = []
+
+ if CATEGORY_MAP[resource.category] != "RESOURCE":
+ # Attributes, segments, and metrics do not have attributes
+ return resource_attributes
+
+ attributed_resources = set(resource.attribute_resources)
+ for field in api_objects:
+ root_object_name = field.name.split(".")[0]
+ does_field_exist_on_resource = (
+ root_object_name == resource.name
+ or root_object_name in attributed_resources
+ )
+ is_field_an_attribute = CATEGORY_MAP[field.category] == "ATTRIBUTE"
+ if is_field_an_attribute and does_field_exist_on_resource:
+ resource_attributes.append(field.name)
+ return resource_attributes
+
+
+def get_segments(resource_schema, resource):
+ resource_segments = []
+
+ if resource["category"] != "RESOURCE":
+ # Attributes, segments, and metrics do not have attributes
+ return resource_segments
+
+ segments = resource["segments"]
+ for segment in segments:
+ if segment.startswith("segments."):
+ resource_segments.append(segment)
+ else:
+ segment_schema = resource_schema[segment]
+ segment_attributes = [
+ attribute
+ for attribute in segment_schema["attributes"]
+ if attribute.startswith(f"{segment}.")
+ ]
+ resource_segments.extend(segment_attributes)
+ return resource_segments
+
+
+def build_resource_metadata(api_objects, resource):
+ attributes = get_attributes(api_objects, resource)
+
+ # These are the data types returned from google. They are mapped to json schema. UNSPECIFIED and UNKNOWN have never been returned.
+ # 0: "UNSPECIFIED", 1: "UNKNOWN", 2: "BOOLEAN", 3: "DATE", 4: "DOUBLE", 5: "ENUM", 6: "FLOAT", 7: "INT32", 8: "INT64", 9: "MESSAGE", 10: "RESOURCE_NAME", 11: "STRING", 12: "UINT64"
+ data_type_map = {
+ 0: {"type": ["null", "string"]},
+ 1: {"type": ["null", "string"]},
+ 2: {"type": ["null", "boolean"]},
+ 3: {"type": ["null", "string"], "format": "date-time"},
+ 4: {"type": ["null", "string"], "format": "singer.decimal"},
+ 5: {"type": ["null", "string"]},
+ 6: {"type": ["null", "string"], "format": "singer.decimal"},
+ 7: {"type": ["null", "integer"]},
+ 8: {"type": ["null", "integer"]},
+ 9: {"type": ["null", "object", "string"], "properties": {}},
+ 10: {"type": ["null", "object", "string"], "properties": {}},
+ 11: {"type": ["null", "string"]},
+ 12: {"type": ["null", "integer"]},
+ }
+
+ resource_metadata = {
+ "name": resource.name,
+ "category": CATEGORY_MAP[resource.category],
+ "json_schema": dict(data_type_map[resource.data_type]),
+ "selectable": resource.selectable,
+ "filterable": resource.filterable,
+ "sortable": resource.sortable,
+ "selectable_with": set(resource.selectable_with),
+ "metrics": list(resource.metrics),
+ "segments": list(resource.segments),
+ "attributes": attributes,
+ }
+
+ return resource_metadata
+
+
+def get_root_resource_name(field_name):
+ if not (field_name.startswith("segments.") or field_name.startswith("metrics.")):
+ field_root_resource = field_name.split(".")[0]
+ else:
+ field_root_resource = field_name
+
+ return field_root_resource
+
+
+def create_resource_schema(config):
+ """
+ The resource schema is necessary to create a 'source of truth' with regards to the fields
+ Google Ads can return to us. It allows for the discovery of field exclusions and other fun
+ things like data types.
+
+
+ It includes every field Google Ads can return and the possible fields that each resource
+ can return.
+
+ This schema is based off of the Google Ads blog posts for the creation of their query builder:
+ https://ads-developers.googleblog.com/2021/04/the-query-builder-blog-series-part-3.html
+ """
+
+ resource_schema = {}
+
+ api_objects = get_api_objects(config)
+
+ for resource in api_objects:
+ resource_schema[resource.name] = build_resource_metadata(api_objects, resource)
+
+ for resource in resource_schema.values():
+ updated_segments = get_segments(resource_schema, resource)
+ resource["segments"] = updated_segments
+
+ for report in REPORTS:
+ report_object = resource_schema[report]
+ fields = {}
+ attributes = report_object["attributes"]
+ metrics = report_object["metrics"]
+ segments = report_object["segments"]
+ for field in attributes + metrics + segments:
+ field_schema = dict(resource_schema[field])
+
+ fields[field_schema["name"]] = {
+ "field_details": field_schema,
+ "incompatible_fields": [],
+ }
+
+ # Start discovery of field exclusions
+ metrics_and_segments = set(metrics + segments)
+
+ for field_name, field in fields.items():
+ if field["field_details"]["category"] == "ATTRIBUTE":
+ continue
+ for compared_field in metrics_and_segments:
+ field_root_resource = get_root_resource_name(field_name)
+ compared_field_root_resource = get_root_resource_name(compared_field)
+
+ # Fields can be any of the categories in CATEGORY_MAP, but only METRIC & SEGMENT have exclusions, so only check those
+ if (
+ field_name != compared_field
+ and not compared_field.startswith(f"{field_root_resource}.")
+ ) and (
+ fields[compared_field]["field_details"]["category"] == "METRIC"
+ or fields[compared_field]["field_details"]["category"] == "SEGMENT"
+ ):
+
+ field_to_check = field_root_resource or field_name
+ compared_field_to_check = compared_field_root_resource or compared_field
+
+ # Metrics will not be incompatible with other metrics, so don't check those
+ if field_name.startswith("metrics.") and compared_field.startswith("metrics."):
+ continue
+
+ # If a resource is selectable with another resource they should be in
+ # each other's 'selectable_with' list, but Google is missing some of
+ # these so we have to check both ways
+ if (
+ field_to_check not in resource_schema[compared_field_to_check]["selectable_with"]
+ and compared_field_to_check not in resource_schema[field_to_check]["selectable_with"]
+ ):
+ field["incompatible_fields"].append(compared_field)
+
+ report_object["fields"] = fields
+ return resource_schema
+
+
+def do_discover_streams(stream_name_to_resource):
+
+ streams = []
+ for stream_name, stream in stream_name_to_resource.items():
+
+ catalog_entry = {
+ "tap_stream_id": stream_name,
+ "stream": stream_name,
+ "schema": stream.stream_schema,
+ "metadata": singer.metadata.to_list(stream.stream_metadata),
+ }
+ streams.append(catalog_entry)
+
+ return streams
+
+
+def do_discover(resource_schema):
+ core_streams = do_discover_streams(initialize_core_streams(resource_schema))
+ report_streams = do_discover_streams(initialize_reports(resource_schema))
+ streams = []
+ streams.extend(core_streams)
+ streams.extend(report_streams)
+ json.dump({"streams": streams}, sys.stdout, indent=2)
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index c940c39..071ffa7 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -1,119 +1,1839 @@
ACCOUNT_FIELDS = []
-AD_GROUP_FIELDS = []
+AD_GROUP_FIELDS = []
AD_GROUP_AD_FIELDS = []
CAMPAIGN_FIELDS = []
BIDDING_STRATEGY_FIELDS = []
ACCESSIBLE_BIDDING_STRATEGY_FIELDS = []
CAMPAIGN_BUDGET_FIELDS = []
-ACCOUNT_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'customer.manager', 'segments.click_type', 'metrics.clicks', 'metrics.content_budget_lost_impression_share', 'metrics.content_impression_share', 'metrics.content_rank_lost_impression_share', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'segments.hour', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'metrics.invalid_click_rate', 'metrics.invalid_clicks', 'customer.auto_tagging_enabled', 'customer.test_account', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.search_budget_lost_impression_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'metrics.search_rank_lost_impression_share', 'segments.slot', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-ADGROUP_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'ad_group.type', 'segments.ad_network_type', 'ad_group.ad_rotation_mode', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy', 'campaign.bidding_strategy_type', 'metrics.bounce_rate', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'ad_group.display_custom_bid_dimension', 'metrics.content_impression_share', 'metrics.content_rank_lost_impression_share', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'ad_group.cpc_bid_micros', 'ad_group.cpm_bid_micros', 'ad_group.cpv_bid_micros', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'ad_group.effective_target_roas', 'ad_group.effective_target_roas_source', 'metrics.engagement_rate', 'metrics.engagements', 'campaign.manual_cpc.enhanced_cpc_enabled', 'campaign.percent_cpc.enhanced_cpc_enabled', 'segments.external_conversion_source', 'customer.id', 'ad_group.final_url_suffix', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'segments.hour', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'label.resource_name', 'segments.month', 'segments.month_of_year', 'metrics.phone_impressions', 'metrics.phone_calls', 'metrics.phone_through_rate', 'metrics.percent_new_visitors', 'segments.quarter', 'metrics.relative_ctr', 'metrics.search_absolute_top_impression_share', 'metrics.search_budget_lost_absolute_top_impression_share', 'metrics.search_budget_lost_top_impression_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'metrics.search_rank_lost_absolute_top_impression_share', 'metrics.search_rank_lost_impression_share', 'metrics.search_rank_lost_top_impression_share', 'metrics.search_top_impression_share', 'segments.slot', 'ad_group.effective_target_cpa_micros', 'ad_group.effective_target_cpa_source', 'metrics.top_impression_percentage', 'ad_group.tracking_url_template', 'ad_group.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-AD_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'ad_group_ad.ad.legacy_responsive_display_ad.accent_color', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'ad_group_ad.ad_strength', 'ad_group_ad.ad.type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'ad_group_ad.ad.legacy_responsive_display_ad.allow_flexible_color', 'ad_group_ad.ad.added_by_google_ads', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'ad_group.base_ad_group', 'campaign.base_campaign', 'metrics.bounce_rate', 'ad_group_ad.ad.legacy_responsive_display_ad.business_name', 'ad_group_ad.ad.call_ad.phone_number', 'ad_group_ad.ad.legacy_responsive_display_ad.call_to_action_text', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'ad_group_ad.policy_summary.approval_status', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'ad_group_ad.ad.final_mobile_urls', 'ad_group_ad.ad.final_urls', 'ad_group_ad.ad.tracking_url_template', 'ad_group_ad.ad.url_custom_parameters', 'segments.keyword.ad_group_criterion', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week',
- 'ad_group_ad.ad.legacy_responsive_display_ad.description', 'ad_group_ad.ad.expanded_text_ad.description',
- 'ad_group_ad.ad.text_ad.description1', 'ad_group_ad.ad.call_ad.description1',
- 'ad_group_ad.ad.text_ad.description2', 'ad_group_ad.ad.call_ad.description2',
- 'ad_group_criterion.negative',
- 'segments.device', 'ad_group_ad.ad.device_preference', 'ad_group_ad.ad.display_url', 'metrics.engagement_rate', 'metrics.engagements', 'ad_group_ad.ad.legacy_responsive_display_ad.logo_image', 'ad_group_ad.ad.legacy_responsive_display_ad.square_logo_image', 'ad_group_ad.ad.legacy_responsive_display_ad.marketing_image', 'ad_group_ad.ad.legacy_responsive_display_ad.square_marketing_image', 'ad_group_ad.ad.expanded_dynamic_search_ad.description', 'ad_group_ad.ad.expanded_text_ad.description2', 'ad_group_ad.ad.expanded_text_ad.headline_part3', 'segments.external_conversion_source', 'customer.id', 'ad_group_ad.ad.legacy_responsive_display_ad.format_setting', 'ad_group_ad.ad.gmail_ad.header_image', 'ad_group_ad.ad.gmail_ad.teaser.logo_image', 'ad_group_ad.ad.gmail_ad.marketing_image', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_ad.ad.gmail_ad.teaser.business_name', 'ad_group_ad.ad.gmail_ad.teaser.description', 'ad_group_ad.ad.gmail_ad.teaser.headline', 'ad_group_ad.ad.text_ad.headline', 'ad_group_ad.ad.expanded_text_ad.headline_part1', 'ad_group_ad.ad.expanded_text_ad.headline_part2', 'ad_group_ad.ad.id', 'ad_group_ad.ad.image_ad.image_url', 'ad_group_ad.ad.image_ad.pixel_height', 'ad_group_ad.ad.image_ad.pixel_width', 'ad_group_ad.ad.image_ad.mime_type', 'ad_group_ad.ad.image_ad.name', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'label.resource_name', 'label.name', 'ad_group_ad.ad.legacy_responsive_display_ad.long_headline', 'ad_group_ad.ad.legacy_responsive_display_ad.main_color', 'ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text', 'ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text_color', 'ad_group_ad.ad.gmail_ad.marketing_image_headline', 'ad_group_ad.ad.gmail_ad.marketing_image_description', 'segments.month', 'segments.month_of_year', 'ad_group_ad.ad.responsive_display_ad.accent_color', 'ad_group_ad.ad.responsive_display_ad.allow_flexible_color', 'ad_group_ad.ad.responsive_display_ad.business_name', 'ad_group_ad.ad.responsive_display_ad.call_to_action_text', 'ad_group_ad.ad.responsive_display_ad.descriptions', 'ad_group_ad.ad.responsive_display_ad.price_prefix', 'ad_group_ad.ad.responsive_display_ad.promo_text', 'ad_group_ad.ad.responsive_display_ad.format_setting', 'ad_group_ad.ad.responsive_display_ad.headlines', 'ad_group_ad.ad.responsive_display_ad.logo_images', 'ad_group_ad.ad.responsive_display_ad.square_logo_images', 'ad_group_ad.ad.responsive_display_ad.long_headline', 'ad_group_ad.ad.responsive_display_ad.main_color', 'ad_group_ad.ad.responsive_display_ad.marketing_images', 'ad_group_ad.ad.responsive_display_ad.square_marketing_images', 'ad_group_ad.ad.responsive_display_ad.youtube_videos', 'ad_group_ad.ad.expanded_text_ad.path1', 'ad_group_ad.ad.expanded_text_ad.path2', 'metrics.percent_new_visitors',
- 'ad_group_ad.policy_summary.policy_topic_entries',
- #'ad_group_ad.policy_summary.review_state',
- 'ad_group_ad.policy_summary.review_status',
- 'ad_group_ad.policy_summary.approval_status',
- 'ad_group_ad.ad.legacy_responsive_display_ad.price_prefix', 'ad_group_ad.ad.legacy_responsive_display_ad.promo_text', 'segments.quarter', 'ad_group_ad.ad.responsive_search_ad.descriptions', 'ad_group_ad.ad.responsive_search_ad.headlines', 'ad_group_ad.ad.responsive_search_ad.path1', 'ad_group_ad.ad.responsive_search_ad.path2', 'ad_group_ad.ad.legacy_responsive_display_ad.short_headline', 'segments.slot', 'ad_group_ad.status', 'ad_group_ad.ad.system_managed_resource_source', 'metrics.top_impression_percentage', 'ad_group_ad.ad.app_ad.descriptions', 'ad_group_ad.ad.app_ad.headlines', 'ad_group_ad.ad.app_ad.html5_media_bundles', 'ad_group_ad.ad.app_ad.images', 'ad_group_ad.ad.app_ad.mandatory_ad_text', 'ad_group_ad.ad.app_ad.youtube_videos', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-AGE_RANGE_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy', 'bidding_strategy.name', 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.age_range.type', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-AUDIENCE_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy',
- 'campaign_criterion.bid_modifier',
- 'ad_group_criterion.bid_modifier',
- 'bidding_strategy.name',
- #'campaign.bidding_strategy.type',
- 'campaign.bidding_strategy_type',
- 'ad_group.campaign', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion',
- #'This should be campaign/ad group criterion depending on the view.',
- 'ad_group_criterion.effective_cpc_bid_micros',
- 'ad_group_criterion.effective_cpc_bid_source',
- 'ad_group_criterion.effective_cpm_bid_micros',
- 'ad_group_criterion.effective_cpm_bid_source',
- #'This should be campaign/ad group bid modifier depending on the view.',
- 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'segments.slot', 'ad_group_criterion.status', 'ad_group.tracking_url_template', 'ad_group.url_custom_parameters', 'user_list.name', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-AUTOMATIC_PLACEMENTS_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'Use group_placement_view.placement_type or group_placement_view.target_url.', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'Returns domain name for websites and YouTube channel name for YouTube channels', 'group_placement_view.target_url', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-BID_GOAL_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'bidding_strategy.campaign_count', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'segments.hour', 'bidding_strategy.id', 'metrics.impressions', 'segments.month', 'segments.month_of_year', 'bidding_strategy.name must be selected withy the resources bidding_strategy or campaign.', 'bidding_strategy.non_removed_campaign_count', 'segments.quarter', 'bidding_strategy.status', 'bidding_strategy.target_cpa.target_cpa_micros', 'bidding_strategy.target_cpa.cpc_bid_ceiling_micros', 'bidding_strategy.target_cpa.cpc_bid_floor_micros', 'bidding_strategy.target_roas.target_roas', 'bidding_strategy.target_roas.cpc_bid_ceiling_micros', 'bidding_strategy.target_roas.cpc_bid_floor_micros', 'bidding_strategy.target_spend.cpc_bid_ceiling_micros', 'bidding_strategy.target_spend.target_spend_micros', 'bidding_strategy.type', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-BUDGET_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'campaign_budget.amount_micros', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'segments.budget_campaign_association_status.status', 'campaign_budget.id', 'campaign_budget.name', 'campaign_budget.reference_count', 'campaign_budget.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'campaign_budget.delivery_method', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'campaign_budget.has_recommended_budget', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'campaign_budget.explicitly_shared', 'campaign_budget.period', 'campaign_budget.recommended_budget_amount_micros', 'campaign_budget.recommended_budget_estimated_change_weekly_clicks', 'campaign_budget.recommended_budget_estimated_change_weekly_cost_micros', 'campaign_budget.recommended_budget_estimated_change_weekly_interactions', 'campaign_budget.recommended_budget_estimated_change_weekly_views', 'campaign_budget.total_amount_micros', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions']
-CALL_METRICS_CALL_DETAILS_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'call_view.call_duration_seconds', 'call_view.end_call_date_time', 'call_view.start_call_date_time', 'call_view.call_status', 'call_view.call_tracking_display_location', 'call_view.type', 'call_view.caller_area_code', 'call_view.caller_country_code', 'campaign.id', 'campaign.name', 'campaign.status', 'customer.descriptive_name',
- #'segments.date',
- #'segments.day_of_week',
- 'customer.id',
- #'segments.hour',
- #'segments.month',
- #'segments.month_of_year',
- #'segments.quarter',
- #'segments.week',
- #'segments.year'
-]
-CAMPAIGN_AD_SCHEDULE_TARGET_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign_criterion.bid_modifier', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'campaign_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-CAMPAIGN_CRITERIA_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'campaign.base_campaign', 'campaign.id', 'campaign.name', 'campaign.status', 'campaign_criterion.keyword.text OR campaign_criterion.placement.url, etc.', 'campaign_criterion.type', 'customer.descriptive_name', 'customer.id', 'campaign_criterion.criterion_id', 'campaign_criterion.negative']
-CAMPAIGN_LOCATION_TARGET_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign_criterion.bid_modifier', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'campaign_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'campaign_criterion.negative', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-CAMPAIGN_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'segments.ad_network_type', 'campaign.advertising_channel_sub_type', 'campaign.advertising_channel_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'campaign_budget.amount_micros', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'campaign.base_campaign', 'campaign.bidding_strategy', 'bidding_strategy.name', 'campaign.bidding_strategy_type', 'metrics.bounce_rate', 'campaign.campaign_budget',
- # "Not available with 'FROM campaign'. However, you can retrieve device type bid modifiers via 'FROM campaign_criterion' queries.",
- 'campaign_criterion.device.type',
- 'campaign.id',
- #"Not available with 'FROM campaign'. However, you can retrieve device type bid modifiers via 'FROM campaign_criterion' queries.",
- 'campaign.name', 'campaign.status',
- #"Not available with 'FROM campaign'. However, you can retrieve device type bid modifiers via 'FROM campaign_criterion' queries.",
- 'campaign.experiment_type', 'segments.click_type', 'metrics.clicks', 'metrics.content_budget_lost_impression_share', 'metrics.content_impression_share', 'metrics.content_rank_lost_impression_share', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_attribution_event_type', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'campaign.end_date', 'metrics.engagement_rate', 'metrics.engagements', 'campaign.manual_cpc.enhanced_cpc_enabled', 'campaign.percent_cpc.enhanced_cpc_enabled', 'segments.external_conversion_source', 'customer.id', 'campaign.final_url_suffix', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'campaign_budget.has_recommended_budget', 'segments.hour', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'metrics.invalid_click_rate', 'metrics.invalid_clicks', 'campaign_budget.explicitly_shared',
- #'Select label.resource_name from the resource campaign_label',
- #'Select label.resource_name from the resource campaign_label',
- 'label.resource_name',
- 'campaign.maximize_conversion_value.target_roas', 'segments.month', 'segments.month_of_year', 'metrics.phone_impressions', 'metrics.phone_calls', 'metrics.phone_through_rate', 'metrics.percent_new_visitors', 'campaign_budget.period', 'segments.quarter', 'campaign_budget.recommended_budget_amount_micros', 'metrics.relative_ctr', 'metrics.search_absolute_top_impression_share', 'metrics.search_budget_lost_absolute_top_impression_share', 'metrics.search_budget_lost_impression_share', 'metrics.search_budget_lost_top_impression_share', 'metrics.search_click_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'metrics.search_rank_lost_absolute_top_impression_share', 'metrics.search_rank_lost_impression_share', 'metrics.search_rank_lost_top_impression_share', 'metrics.search_top_impression_share', 'campaign.serving_status', 'segments.slot', 'campaign.start_date', 'metrics.top_impression_percentage', 'campaign_budget.total_amount_micros', 'campaign.tracking_url_template', 'campaign.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-CAMPAIGN_SHARED_SET_REPORT_FIELDS = ['customer.descriptive_name', 'campaign.id', 'campaign.name', 'campaign.status', 'customer.id', 'shared_set.id', 'shared_set.name', 'shared_set.type', 'campaign_shared_set.status']
-CLICK_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'click_view.area_of_interest.city', 'click_view.area_of_interest.country', 'click_view.area_of_interest.metro', 'click_view.area_of_interest.most_specific', 'click_view.area_of_interest.region', 'campaign.id', 'click_view.campaign_location_target', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'click_view.ad_group_ad', 'segments.date', 'segments.device', 'customer.id', 'click_view.gclid', 'click_view.location_of_presence.city', 'click_view.location_of_presence.country', 'click_view.location_of_presence.metro', 'click_view.location_of_presence.most_specific', 'click_view.location_of_presence.region', 'segments.month_of_year', 'click_view.page_number', 'segments.slot', 'click_view.user_list']
-DISPLAY_KEYWORD_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy',
- #'bidding_strategy.name must be selected with the resources bidding_strategy and campaign.',
- 'bidding_strategy.name',
- 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.effective_cpv_bid_micros', 'ad_group_criterion.effective_cpv_bid_source', 'ad_group_criterion.keyword.text', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-DISPLAY_TOPICS_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy',
- #'bidding_strategy.name must be selected with the resources bidding_strategy and campaign.',
- 'bidding_strategy.name',
- 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.topic.path', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'ad_group_criterion.topic.topic_constant', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-GENDER_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy', 'bidding_strategy.name', 'bidding_strategy.type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.gender.type', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-GEO_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.geo_target_city', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion',
- #'Use geographic_view.country_criterion_id or user_location_view.country_criterion_id depending upon which view you want',
- 'geographic_view.country_criterion_id',
- 'user_location_view.country_criterion_id',
- 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'user_location_view.targeting_location', 'geographic_view.location_type', 'segments.geo_target_metro', 'segments.month', 'segments.month_of_year', 'segments.geo_target_most_specific_location', 'segments.quarter', 'segments.geo_target_region', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-KEYWORDLESS_QUERY_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'segments.webpage', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'dynamic_search_ads_search_term_view.headline', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'dynamic_search_ads_search_term_view.search_term', 'dynamic_search_ads_search_term_view.landing_page', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'segments.week', 'segments.year']
-KEYWORDS_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'ad_group_criterion.approval_status', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'metrics.average_page_views', 'metrics.average_time_on_site', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy', 'campaign.bidding_strategy_type', 'metrics.bounce_rate', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_adjustment', 'segments.conversion_or_adjustment_lag_bucket', 'segments.conversion_action_category', 'segments.conversion_lag_bucket', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cost_per_current_model_attributed_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.quality_info.creative_quality_score', 'ad_group_criterion.keyword.text', 'metrics.cross_device_conversions', 'metrics.ctr', 'metrics.current_model_attributed_conversions_value', 'metrics.current_model_attributed_conversions', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements',
- # 'campaign.manual_cpc.enhanced_cpc_enabled || campaign.percent_cpc.enhanced_cpc_enabled',
- 'campaign.manual_cpc.enhanced_cpc_enabled',
- 'campaign.percent_cpc.enhanced_cpc_enabled',
- 'ad_group_criterion.position_estimates.estimated_add_clicks_at_first_position_cpc', 'ad_group_criterion.position_estimates.estimated_add_cost_at_first_position_cpc', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_url_suffix', 'ad_group_criterion.final_urls', 'ad_group_criterion.position_estimates.first_page_cpc_micros', 'ad_group_criterion.position_estimates.first_position_cpc_micros', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'metrics.historical_creative_quality_score', 'metrics.historical_landing_page_quality_score', 'metrics.historical_quality_score', 'metrics.historical_search_predicted_ctr', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group_criterion.keyword.match_type',
- #'Select label.resource_name from the resource ad_group_label',
- #'Select label.name from the resource ad_group_label',
- 'label.resource_name',
- 'label.name',
- 'segments.month', 'segments.month_of_year', 'metrics.percent_new_visitors', 'ad_group_criterion.quality_info.post_click_quality_score', 'ad_group_criterion.quality_info.quality_score', 'segments.quarter', 'metrics.search_absolute_top_impression_share', 'metrics.search_budget_lost_absolute_top_impression_share', 'metrics.search_budget_lost_top_impression_share', 'metrics.search_exact_match_impression_share', 'metrics.search_impression_share', 'ad_group_criterion.quality_info.search_predicted_ctr', 'metrics.search_rank_lost_absolute_top_impression_share', 'metrics.search_rank_lost_impression_share', 'metrics.search_rank_lost_top_impression_share', 'metrics.search_top_impression_share', 'segments.slot', 'ad_group_criterion.status', 'ad_group_criterion.system_serving_status', 'metrics.top_impression_percentage', 'ad_group_criterion.position_estimates.top_of_page_cpc_micros', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.value_per_current_model_attributed_conversion', 'ad_group_criterion.topic.topic_constant', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-LABEL_REPORT_FIELDS = ['customer.descriptive_name', 'customer.id', 'label.id', 'label.name', 'deprecated']
-LANDING_PAGE_REPORT_FIELDS = ['metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'campaign.advertising_channel_type', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'metrics.conversions_from_interactions_rate', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'expanded_landing_page_view.expanded_final_url', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'metrics.mobile_friendly_clicks_percentage', 'metrics.valid_accelerated_mobile_pages_clicks_percentage', 'segments.quarter', 'segments.slot', 'metrics.speed_score', 'landing_page_view.unexpanded_final_url', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'segments.week', 'segments.year']
-PAID_ORGANIC_QUERY_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'metrics.average_cpc', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'metrics.combined_clicks', 'metrics.combined_clicks_per_query', 'metrics.combined_queries', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'customer.id', 'metrics.impressions', 'segments.keyword.ad_group_criterion', 'segments.keyword.info.text', 'segments.month', 'segments.month_of_year', 'metrics.organic_clicks', 'metrics.organic_clicks_per_query', 'metrics.organic_impressions', 'metrics.organic_impressions_per_query', 'metrics.organic_queries', 'segments.quarter', 'paid_organic_search_term_view.search_term', 'segments.search_engine_results_page_type', 'segments.week', 'segments.year']
-PARENTAL_STATUS_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'campaign.bidding_strategy', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.parental_status.type', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-PLACEHOLDER_FEED_ITEM_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'ad_group_ad.resource_name', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'feed_item.attribute_values', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device',
- #'feed_item_target.device is available with FROM feed_item_target',
- 'feed_item_target.device',
- #'See feed_item.policy_infos for policy information.',
- 'feed_item.policy_infos',
- 'feed_item.end_date_time', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'feed_item.feed', 'feed_item.id', 'feed_item_target.feed_item_target_id', 'feed_item.geo_targeting_restriction', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.interaction_on_this_extension', 'feed_item_target.keyword.match_type', 'feed_item_target.feed_item_target_id', 'feed_item_target.keyword.match_type', 'feed_item_target.keyword.text', 'segments.month', 'segments.month_of_year', 'segments.placeholder_type', 'segments.quarter', 'feed_item_target.ad_schedule', 'segments.slot', 'feed_item.start_date_time', 'feed_item.status', 'feed_item_target.ad_group', 'feed_item_target.campaign', 'feed_item.url_custom_parameters',
- #'See feed_item.policy_infos for policy information.',
- 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'segments.week', 'segments.year']
-PLACEHOLDER_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv',
- #'campaign',
- 'campaign.id',
- 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'ad_group_ad.resource_name', 'feed_placeholder_view.placeholder_type', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'segments.slot', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-PLACEMENT_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'ad_group.base_ad_group', 'campaign.base_campaign', 'ad_group_criterion.bid_modifier', 'campaign.bidding_strategy', 'bidding_strategy.name', 'bidding_strategy.type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.all_conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.effective_cpc_bid_micros', 'ad_group_criterion.effective_cpc_bid_source', 'ad_group_criterion.effective_cpm_bid_micros', 'ad_group_criterion.effective_cpm_bid_source', 'ad_group_criterion.placement.url', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device',
- #'Returns browser url for websites and for YouTube, video and channel.',
- # BUG We don't know what to do with this. We looked for a browser url type thing in the query builder
- 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_mobile_urls', 'ad_group_criterion.final_urls', 'metrics.gmail_forwards', 'metrics.gmail_saves', 'metrics.gmail_secondary_clicks', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'ad_group_criterion.negative', 'ad_group.targeting_setting.target_restrictions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'ad_group_criterion.status', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-PRODUCT_PARTITION_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'metrics.benchmark_average_max_cpc', 'metrics.benchmark_ctr', 'campaign.bidding_strategy_type', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_criterion.cpc_bid_micros', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'ad_group_criterion.final_url_suffix', 'ad_group_criterion.criterion_id', 'metrics.impressions', 'ad_group_criterion.negative', 'segments.month', 'segments.month_of_year', 'ad_group_criterion.listing_group.parent_ad_group_criterion', 'ad_group_criterion.listing_group.type', 'segments.quarter', 'metrics.search_absolute_top_impression_share', 'metrics.search_click_share', 'metrics.search_impression_share', 'ad_group_criterion.tracking_url_template', 'ad_group_criterion.url_custom_parameters', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-# RESOURCES = ['customer', 'ad_group_ad', 'ad_group', 'age_range_view', 'campaign_audience_view', 'group_placement_view', 'bidding_strategy', 'campaign_budget', 'call_view', 'ad_schedule_view', 'campaign_criterion', 'campaign', 'campaign_shared_set', 'location_view', 'click_view', 'display_keyword_view', 'topic_view', 'gender_view', 'geographic_view', 'dynamic_search_ads_search_term_view', 'keyword_view', 'label', 'landing_page_view', 'paid_organic_search_term_view', 'parental_status_view', 'feed_item', 'feed_placeholder_view', 'managed_placement_view', 'product_group_view', 'search_term_view', 'shared_criterion', 'shared_set', 'shopping_performance_view', 'detail_placement_view', 'distance_view', 'video']
-SEARCH_QUERY_PERFORMANCE_REPORT_FIELDS = ['metrics.absolute_top_impression_percentage', 'customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_ad.ad.id', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'ad_group_ad.ad.final_urls', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.keyword.ad_group_criterion', 'segments.keyword.info.text', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'search_term_view.search_term', 'segments.search_term_match_type', 'search_term_view.status', 'metrics.top_impression_percentage', 'ad_group_ad.ad.tracking_url_template', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-SHARED_SET_CRITERIA_REPORT_FIELDS = ['customer.descriptive_name', 'shared_criterion.keyword.text OR shared_criterion.placement.url, etc.', 'customer.id', 'shared_criterion.criterion_id', 'shared_criterion.keyword.match_type', 'shared_set.id']
-SHOPPING_PERFORMANCE_REPORT_FIELDS = ['customer.descriptive_name', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'segments.product_aggregator_id', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'segments.product_brand', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.product_bidding_category_level1', 'segments.product_bidding_category_level2', 'segments.product_bidding_category_level3', 'segments.product_bidding_category_level4', 'segments.product_bidding_category_level5', 'segments.product_channel', 'segments.product_channel_exclusivity', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'segments.product_country', 'metrics.cross_device_conversions', 'metrics.ctr', 'segments.product_custom_attribute0', 'segments.product_custom_attribute1', 'segments.product_custom_attribute2', 'segments.product_custom_attribute3', 'segments.product_custom_attribute4', 'segments.date', 'segments.day_of_week', 'segments.device', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'segments.product_language', 'segments.product_merchant_id', 'segments.month', 'segments.product_item_id', 'segments.product_condition', 'segments.product_title', 'segments.product_type_l1', 'segments.product_type_l2', 'segments.product_type_l3', 'segments.product_type_l4', 'segments.product_type_l5', 'segments.quarter', 'metrics.search_absolute_top_impression_share', 'metrics.search_click_share', 'metrics.search_impression_share', 'segments.product_store_id', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'segments.week', 'segments.year']
-URL_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'metrics.active_view_cpm', 'metrics.active_view_ctr', 'metrics.active_view_impressions', 'metrics.active_view_measurability', 'metrics.active_view_measurable_cost_micros', 'metrics.active_view_measurable_impressions', 'metrics.active_view_viewability', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cost', 'metrics.average_cpc', 'metrics.average_cpe', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.all_conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'detail_placement_view.display_name', 'detail_placement_view.group_placement_target_url', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'metrics.interaction_rate', 'metrics.interaction_event_types', 'metrics.interactions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'detail_placement_view.target_url', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-USER_AD_DISTANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpc', 'metrics.average_cpm', 'campaign.id', 'campaign.name', 'campaign.status', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'distance_view.distance_bucket', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'metrics.value_per_conversion', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
-VIDEO_PERFORMANCE_REPORT_FIELDS = ['customer.currency_code', 'customer.descriptive_name', 'customer.time_zone', 'ad_group.id', 'ad_group.name', 'ad_group.status', 'segments.ad_network_type', 'metrics.all_conversions_from_interactions_rate', 'metrics.all_conversions_value', 'metrics.all_conversions', 'metrics.average_cpm', 'metrics.average_cpv', 'campaign.id', 'campaign.name', 'campaign.status', 'segments.click_type', 'metrics.clicks', 'segments.conversion_action_category', 'metrics.all_conversions_from_interactions_rate', 'segments.conversion_action', 'segments.conversion_action_name', 'metrics.conversions_value', 'metrics.conversions', 'metrics.cost_micros', 'metrics.cost_per_all_conversions', 'metrics.cost_per_conversion', 'ad_group_ad.ad.id', 'ad_group_ad.status', 'metrics.cross_device_conversions', 'metrics.ctr', 'customer.descriptive_name', 'segments.date', 'segments.day_of_week', 'segments.device', 'metrics.engagement_rate', 'metrics.engagements', 'segments.external_conversion_source', 'customer.id', 'metrics.impressions', 'segments.month', 'segments.month_of_year', 'segments.quarter', 'metrics.value_per_all_conversions', 'video.channel_id', 'video.duration_millis', 'video.id', 'metrics.video_quartile_p100_rate', 'metrics.video_quartile_p25_rate', 'metrics.video_quartile_p50_rate', 'metrics.video_quartile_p75_rate', 'video.title', 'metrics.video_view_rate', 'metrics.video_views', 'metrics.view_through_conversions', 'segments.week', 'segments.year']
+ACCOUNT_PERFORMANCE_REPORT_FIELDS = [
+ "customer.auto_tagging_enabled",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.manager",
+ "customer.test_account",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.content_budget_lost_impression_share",
+ "metrics.content_impression_share",
+ "metrics.content_rank_lost_impression_share",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.invalid_click_rate",
+ "metrics.invalid_clicks",
+ "metrics.search_budget_lost_impression_share",
+ "metrics.search_exact_match_impression_share",
+ "metrics.search_impression_share",
+ "metrics.search_rank_lost_impression_share",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.conversion_adjustment",
+ "segments.conversion_lag_bucket",
+ "segments.conversion_or_adjustment_lag_bucket",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.hour",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+ADGROUP_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.ad_rotation_mode",
+ "ad_group.base_ad_group",
+ "ad_group.cpc_bid_micros",
+ "ad_group.cpm_bid_micros",
+ "ad_group.cpv_bid_micros",
+ "ad_group.display_custom_bid_dimension",
+ "ad_group.effective_target_cpa_micros",
+ "ad_group.effective_target_cpa_source",
+ "ad_group.effective_target_roas",
+ "ad_group.effective_target_roas_source",
+ "ad_group.final_url_suffix",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group.tracking_url_template",
+ "ad_group.type",
+ "ad_group.url_custom_parameters",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.id",
+ "campaign.manual_cpc.enhanced_cpc_enabled",
+ "campaign.name",
+ "campaign.percent_cpc.enhanced_cpc_enabled",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.absolute_top_impression_percentage",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.average_page_views",
+ "metrics.average_time_on_site",
+ "metrics.bounce_rate",
+ "metrics.clicks",
+ "metrics.content_impression_share",
+ "metrics.content_rank_lost_impression_share",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cost_per_current_model_attributed_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.current_model_attributed_conversions",
+ "metrics.current_model_attributed_conversions_value",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.percent_new_visitors",
+ "metrics.phone_calls",
+ "metrics.phone_impressions",
+ "metrics.phone_through_rate",
+ "metrics.relative_ctr",
+ "metrics.search_absolute_top_impression_share",
+ "metrics.search_budget_lost_absolute_top_impression_share",
+ "metrics.search_budget_lost_top_impression_share",
+ "metrics.search_exact_match_impression_share",
+ "metrics.search_impression_share",
+ "metrics.search_rank_lost_absolute_top_impression_share",
+ "metrics.search_rank_lost_impression_share",
+ "metrics.search_rank_lost_top_impression_share",
+ "metrics.search_top_impression_share",
+ "metrics.top_impression_percentage",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.value_per_current_model_attributed_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.conversion_adjustment",
+ "segments.conversion_lag_bucket",
+ "segments.conversion_or_adjustment_lag_bucket",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.hour",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+AD_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group_ad.ad.added_by_google_ads",
+ "ad_group_ad.ad.app_ad.descriptions",
+ "ad_group_ad.ad.app_ad.headlines",
+ "ad_group_ad.ad.app_ad.html5_media_bundles",
+ "ad_group_ad.ad.app_ad.images",
+ "ad_group_ad.ad.app_ad.mandatory_ad_text",
+ "ad_group_ad.ad.app_ad.youtube_videos",
+ "ad_group_ad.ad.call_ad.description1",
+ "ad_group_ad.ad.call_ad.description2",
+ "ad_group_ad.ad.call_ad.phone_number",
+ "ad_group_ad.ad.device_preference",
+ "ad_group_ad.ad.display_url",
+ "ad_group_ad.ad.expanded_dynamic_search_ad.description",
+ "ad_group_ad.ad.expanded_text_ad.description",
+ "ad_group_ad.ad.expanded_text_ad.description2",
+ "ad_group_ad.ad.expanded_text_ad.headline_part1",
+ "ad_group_ad.ad.expanded_text_ad.headline_part2",
+ "ad_group_ad.ad.expanded_text_ad.headline_part3",
+ "ad_group_ad.ad.expanded_text_ad.path1",
+ "ad_group_ad.ad.expanded_text_ad.path2",
+ "ad_group_ad.ad.final_mobile_urls",
+ "ad_group_ad.ad.final_urls",
+ "ad_group_ad.ad.gmail_ad.header_image",
+ "ad_group_ad.ad.gmail_ad.marketing_image",
+ "ad_group_ad.ad.gmail_ad.marketing_image_description",
+ "ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text",
+ "ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text_color",
+ "ad_group_ad.ad.gmail_ad.marketing_image_headline",
+ "ad_group_ad.ad.gmail_ad.teaser.business_name",
+ "ad_group_ad.ad.gmail_ad.teaser.description",
+ "ad_group_ad.ad.gmail_ad.teaser.headline",
+ "ad_group_ad.ad.gmail_ad.teaser.logo_image",
+ "ad_group_ad.ad.id",
+ "ad_group_ad.ad.image_ad.image_url",
+ "ad_group_ad.ad.image_ad.mime_type",
+ "ad_group_ad.ad.image_ad.name",
+ "ad_group_ad.ad.image_ad.pixel_height",
+ "ad_group_ad.ad.image_ad.pixel_width",
+ "ad_group_ad.ad.legacy_responsive_display_ad.accent_color",
+ "ad_group_ad.ad.legacy_responsive_display_ad.allow_flexible_color",
+ "ad_group_ad.ad.legacy_responsive_display_ad.business_name",
+ "ad_group_ad.ad.legacy_responsive_display_ad.call_to_action_text",
+ "ad_group_ad.ad.legacy_responsive_display_ad.description",
+ "ad_group_ad.ad.legacy_responsive_display_ad.format_setting",
+ "ad_group_ad.ad.legacy_responsive_display_ad.logo_image",
+ "ad_group_ad.ad.legacy_responsive_display_ad.long_headline",
+ "ad_group_ad.ad.legacy_responsive_display_ad.main_color",
+ "ad_group_ad.ad.legacy_responsive_display_ad.marketing_image",
+ "ad_group_ad.ad.legacy_responsive_display_ad.price_prefix",
+ "ad_group_ad.ad.legacy_responsive_display_ad.promo_text",
+ "ad_group_ad.ad.legacy_responsive_display_ad.short_headline",
+ "ad_group_ad.ad.legacy_responsive_display_ad.square_logo_image",
+ "ad_group_ad.ad.legacy_responsive_display_ad.square_marketing_image",
+ "ad_group_ad.ad.responsive_display_ad.accent_color",
+ "ad_group_ad.ad.responsive_display_ad.allow_flexible_color",
+ "ad_group_ad.ad.responsive_display_ad.business_name",
+ "ad_group_ad.ad.responsive_display_ad.call_to_action_text",
+ "ad_group_ad.ad.responsive_display_ad.descriptions",
+ "ad_group_ad.ad.responsive_display_ad.format_setting",
+ "ad_group_ad.ad.responsive_display_ad.headlines",
+ "ad_group_ad.ad.responsive_display_ad.logo_images",
+ "ad_group_ad.ad.responsive_display_ad.long_headline",
+ "ad_group_ad.ad.responsive_display_ad.main_color",
+ "ad_group_ad.ad.responsive_display_ad.marketing_images",
+ "ad_group_ad.ad.responsive_display_ad.price_prefix",
+ "ad_group_ad.ad.responsive_display_ad.promo_text",
+ "ad_group_ad.ad.responsive_display_ad.square_logo_images",
+ "ad_group_ad.ad.responsive_display_ad.square_marketing_images",
+ "ad_group_ad.ad.responsive_display_ad.youtube_videos",
+ "ad_group_ad.ad.responsive_search_ad.descriptions",
+ "ad_group_ad.ad.responsive_search_ad.headlines",
+ "ad_group_ad.ad.responsive_search_ad.path1",
+ "ad_group_ad.ad.responsive_search_ad.path2",
+ "ad_group_ad.ad.system_managed_resource_source",
+ "ad_group_ad.ad.text_ad.description1",
+ "ad_group_ad.ad.text_ad.description2",
+ "ad_group_ad.ad.text_ad.headline",
+ "ad_group_ad.ad.tracking_url_template",
+ "ad_group_ad.ad.type",
+ "ad_group_ad.ad.url_custom_parameters",
+ "ad_group_ad.ad_strength",
+ "ad_group_ad.policy_summary.approval_status",
+ "ad_group_ad.policy_summary.approval_status",
+ "ad_group_ad.policy_summary.policy_topic_entries",
+ "ad_group_ad.policy_summary.review_status",
+ "ad_group_ad.status",
+ "campaign.base_campaign",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.absolute_top_impression_percentage",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.average_page_views",
+ "metrics.average_time_on_site",
+ "metrics.bounce_rate",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cost_per_current_model_attributed_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.current_model_attributed_conversions",
+ "metrics.current_model_attributed_conversions_value",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.percent_new_visitors",
+ "metrics.top_impression_percentage",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.value_per_current_model_attributed_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.conversion_adjustment",
+ "segments.conversion_lag_bucket",
+ "segments.conversion_or_adjustment_lag_bucket",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.keyword.ad_group_criterion",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+AGE_RANGE_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group.targeting_setting.target_restrictions",
+ "ad_group_criterion.age_range.type",
+ "ad_group_criterion.bid_modifier",
+ "ad_group_criterion.criterion_id",
+ "ad_group_criterion.effective_cpc_bid_micros",
+ "ad_group_criterion.effective_cpc_bid_source",
+ "ad_group_criterion.effective_cpm_bid_micros",
+ "ad_group_criterion.effective_cpm_bid_source",
+ "ad_group_criterion.final_mobile_urls",
+ "ad_group_criterion.final_urls",
+ "ad_group_criterion.negative",
+ "ad_group_criterion.status",
+ "ad_group_criterion.tracking_url_template",
+ "ad_group_criterion.url_custom_parameters",
+ "bidding_strategy.name",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+]
+AD_GROUP_AUDIENCE_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.campaign",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group.targeting_setting.target_restrictions",
+ "ad_group.tracking_url_template",
+ "ad_group.url_custom_parameters",
+ "ad_group_criterion.bid_modifier",
+ "ad_group_criterion.criterion_id",
+ "ad_group_criterion.effective_cpc_bid_micros",
+ "ad_group_criterion.effective_cpc_bid_source",
+ "ad_group_criterion.effective_cpm_bid_micros",
+ "ad_group_criterion.effective_cpm_bid_source",
+ "ad_group_criterion.final_mobile_urls",
+ "ad_group_criterion.final_urls",
+ "ad_group_criterion.status",
+ "bidding_strategy.name",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+ "user_list.name",
+]
+CAMPAIGN_AUDIENCE_PERFORMANCE_REPORT_FIELDS = [
+ "bidding_strategy.name",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.name",
+ "campaign.status",
+ "campaign_criterion.bid_modifier",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+ "user_list.name",
+]
+CAMPAIGN_PERFORMANCE_REPORT_FIELDS = [
+ "bidding_strategy.name",
+ "campaign.advertising_channel_sub_type",
+ "campaign.advertising_channel_type",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.campaign_budget",
+ "campaign.end_date",
+ "campaign.experiment_type",
+ "campaign.final_url_suffix",
+ "campaign.id",
+ "campaign.manual_cpc.enhanced_cpc_enabled",
+ "campaign.maximize_conversion_value.target_roas",
+ "campaign.name",
+ "campaign.percent_cpc.enhanced_cpc_enabled",
+ "campaign.serving_status",
+ "campaign.start_date",
+ "campaign.status",
+ "campaign.tracking_url_template",
+ "campaign.url_custom_parameters",
+ "campaign_budget.amount_micros",
+ "campaign_budget.explicitly_shared",
+ "campaign_budget.has_recommended_budget",
+ "campaign_budget.period",
+ "campaign_budget.recommended_budget_amount_micros",
+ "campaign_budget.total_amount_micros",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.absolute_top_impression_percentage",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.average_page_views",
+ "metrics.average_time_on_site",
+ "metrics.bounce_rate",
+ "metrics.clicks",
+ "metrics.content_budget_lost_impression_share",
+ "metrics.content_impression_share",
+ "metrics.content_rank_lost_impression_share",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cost_per_current_model_attributed_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.current_model_attributed_conversions",
+ "metrics.current_model_attributed_conversions_value",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.invalid_click_rate",
+ "metrics.invalid_clicks",
+ "metrics.percent_new_visitors",
+ "metrics.phone_calls",
+ "metrics.phone_impressions",
+ "metrics.phone_through_rate",
+ "metrics.relative_ctr",
+ "metrics.search_absolute_top_impression_share",
+ "metrics.search_budget_lost_absolute_top_impression_share",
+ "metrics.search_budget_lost_impression_share",
+ "metrics.search_budget_lost_top_impression_share",
+ "metrics.search_click_share",
+ "metrics.search_exact_match_impression_share",
+ "metrics.search_impression_share",
+ "metrics.search_rank_lost_absolute_top_impression_share",
+ "metrics.search_rank_lost_impression_share",
+ "metrics.search_rank_lost_top_impression_share",
+ "metrics.search_top_impression_share",
+ "metrics.top_impression_percentage",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.value_per_current_model_attributed_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.conversion_adjustment",
+ "segments.conversion_attribution_event_type",
+ "segments.conversion_lag_bucket",
+ "segments.conversion_or_adjustment_lag_bucket",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.hour",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+CLICK_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "click_view.ad_group_ad",
+ "click_view.area_of_interest.city",
+ "click_view.area_of_interest.country",
+ "click_view.area_of_interest.metro",
+ "click_view.area_of_interest.most_specific",
+ "click_view.area_of_interest.region",
+ "click_view.campaign_location_target",
+ "click_view.gclid",
+ "click_view.location_of_presence.city",
+ "click_view.location_of_presence.country",
+ "click_view.location_of_presence.metro",
+ "click_view.location_of_presence.most_specific",
+ "click_view.location_of_presence.region",
+ "click_view.page_number",
+ "click_view.user_list",
+ "customer.descriptive_name",
+ "customer.id",
+ "metrics.clicks",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.date",
+ "segments.device",
+ "segments.month_of_year",
+ "segments.slot",
+]
+DISPLAY_KEYWORD_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group.targeting_setting.target_restrictions",
+ "ad_group_criterion.criterion_id",
+ "ad_group_criterion.effective_cpc_bid_micros",
+ "ad_group_criterion.effective_cpc_bid_source",
+ "ad_group_criterion.effective_cpm_bid_micros",
+ "ad_group_criterion.effective_cpm_bid_source",
+ "ad_group_criterion.effective_cpv_bid_micros",
+ "ad_group_criterion.effective_cpv_bid_source",
+ "ad_group_criterion.final_mobile_urls",
+ "ad_group_criterion.final_urls",
+ "ad_group_criterion.keyword.text",
+ "ad_group_criterion.negative",
+ "ad_group_criterion.status",
+ "ad_group_criterion.tracking_url_template",
+ "ad_group_criterion.url_custom_parameters",
+ "bidding_strategy.name", # bidding_strategy.name must be selected with the resources bidding_strategy and campaign.
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+]
+DISPLAY_TOPICS_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group.targeting_setting.target_restrictions",
+ "ad_group_criterion.bid_modifier",
+ "ad_group_criterion.criterion_id",
+ "ad_group_criterion.effective_cpc_bid_micros",
+ "ad_group_criterion.effective_cpc_bid_source",
+ "ad_group_criterion.effective_cpm_bid_micros",
+ "ad_group_criterion.effective_cpm_bid_source",
+ "ad_group_criterion.final_mobile_urls",
+ "ad_group_criterion.final_urls",
+ "ad_group_criterion.negative",
+ "ad_group_criterion.status",
+ "ad_group_criterion.topic.path",
+ "ad_group_criterion.topic.topic_constant",
+ "ad_group_criterion.tracking_url_template",
+ "ad_group_criterion.url_custom_parameters",
+ "bidding_strategy.name", # bidding_strategy.name must be selected with the resources bidding_strategy and campaign.
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+]
+EXPANDED_LANDING_PAGE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "campaign.advertising_channel_type",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "expanded_landing_page_view.expanded_final_url",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.mobile_friendly_clicks_percentage",
+ "metrics.speed_score",
+ "metrics.valid_accelerated_mobile_pages_clicks_percentage",
+ "metrics.value_per_conversion",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+GENDER_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group.targeting_setting.target_restrictions",
+ "ad_group_criterion.bid_modifier",
+ "ad_group_criterion.criterion_id",
+ "ad_group_criterion.effective_cpc_bid_micros",
+ "ad_group_criterion.effective_cpc_bid_source",
+ "ad_group_criterion.effective_cpm_bid_micros",
+ "ad_group_criterion.effective_cpm_bid_source",
+ "ad_group_criterion.final_mobile_urls",
+ "ad_group_criterion.final_urls",
+ "ad_group_criterion.gender.type",
+ "ad_group_criterion.negative",
+ "ad_group_criterion.status",
+ "ad_group_criterion.tracking_url_template",
+ "ad_group_criterion.url_custom_parameters",
+ "bidding_strategy.name",
+ "bidding_strategy.type",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+]
+GEO_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "geographic_view.country_criterion_id",
+ "geographic_view.location_type",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.geo_target_city",
+ "segments.geo_target_metro",
+ "segments.geo_target_most_specific_location",
+ "segments.geo_target_region",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+]
+KEYWORDLESS_QUERY_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "dynamic_search_ads_search_term_view.headline",
+ "dynamic_search_ads_search_term_view.landing_page",
+ "dynamic_search_ads_search_term_view.search_term",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cpc",
+ "metrics.average_cpm",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.impressions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.webpage",
+ "segments.week",
+ "segments.year",
+]
+KEYWORDS_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group_criterion.approval_status",
+ "ad_group_criterion.criterion_id",
+ "ad_group_criterion.effective_cpc_bid_micros",
+ "ad_group_criterion.effective_cpc_bid_source",
+ "ad_group_criterion.effective_cpm_bid_micros",
+ "ad_group_criterion.final_mobile_urls",
+ "ad_group_criterion.final_url_suffix",
+ "ad_group_criterion.final_urls",
+ "ad_group_criterion.keyword.match_type",
+ "ad_group_criterion.keyword.text",
+ "ad_group_criterion.negative",
+ "ad_group_criterion.position_estimates.estimated_add_clicks_at_first_position_cpc",
+ "ad_group_criterion.position_estimates.estimated_add_cost_at_first_position_cpc",
+ "ad_group_criterion.position_estimates.first_page_cpc_micros",
+ "ad_group_criterion.position_estimates.first_position_cpc_micros",
+ "ad_group_criterion.position_estimates.top_of_page_cpc_micros",
+ "ad_group_criterion.quality_info.creative_quality_score",
+ "ad_group_criterion.quality_info.post_click_quality_score",
+ "ad_group_criterion.quality_info.quality_score",
+ "ad_group_criterion.quality_info.search_predicted_ctr",
+ "ad_group_criterion.status",
+ "ad_group_criterion.system_serving_status",
+ "ad_group_criterion.topic.topic_constant",
+ "ad_group_criterion.tracking_url_template",
+ "ad_group_criterion.url_custom_parameters",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.bidding_strategy_type",
+ "campaign.id",
+ "campaign.manual_cpc.enhanced_cpc_enabled",
+ "campaign.name",
+ "campaign.percent_cpc.enhanced_cpc_enabled",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.absolute_top_impression_percentage",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.average_page_views",
+ "metrics.average_time_on_site",
+ "metrics.bounce_rate",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cost_per_current_model_attributed_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.current_model_attributed_conversions",
+ "metrics.current_model_attributed_conversions_value",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.historical_creative_quality_score",
+ "metrics.historical_landing_page_quality_score",
+ "metrics.historical_quality_score",
+ "metrics.historical_search_predicted_ctr",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.percent_new_visitors",
+ "metrics.search_absolute_top_impression_share",
+ "metrics.search_budget_lost_absolute_top_impression_share",
+ "metrics.search_budget_lost_top_impression_share",
+ "metrics.search_exact_match_impression_share",
+ "metrics.search_impression_share",
+ "metrics.search_rank_lost_absolute_top_impression_share",
+ "metrics.search_rank_lost_impression_share",
+ "metrics.search_rank_lost_top_impression_share",
+ "metrics.search_top_impression_share",
+ "metrics.top_impression_percentage",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.value_per_current_model_attributed_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.conversion_adjustment",
+ "segments.conversion_lag_bucket",
+ "segments.conversion_or_adjustment_lag_bucket",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+LANDING_PAGE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "campaign.advertising_channel_type",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "landing_page_view.unexpanded_final_url",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.mobile_friendly_clicks_percentage",
+ "metrics.speed_score",
+ "metrics.valid_accelerated_mobile_pages_clicks_percentage",
+ "metrics.value_per_conversion",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+PLACEHOLDER_FEED_ITEM_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group_ad.resource_name",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "feed_item.attribute_values",
+ "feed_item.end_date_time",
+ "feed_item.feed",
+ "feed_item.geo_targeting_restriction",
+ "feed_item.id",
+ "feed_item.policy_infos",
+ "feed_item.start_date_time",
+ "feed_item.status",
+ "feed_item.url_custom_parameters",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.interaction_on_this_extension",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.placeholder_type",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+PLACEHOLDER_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group_ad.resource_name",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.descriptive_name",
+ "customer.id",
+ "feed_placeholder_view.placeholder_type",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.slot",
+ "segments.week",
+ "segments.year",
+]
+PLACEMENT_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.base_ad_group",
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group.targeting_setting.target_restrictions",
+ "ad_group_criterion.bid_modifier",
+ "ad_group_criterion.criterion_id",
+ "ad_group_criterion.effective_cpc_bid_micros",
+ "ad_group_criterion.effective_cpc_bid_source",
+ "ad_group_criterion.effective_cpm_bid_micros",
+ "ad_group_criterion.effective_cpm_bid_source",
+ "ad_group_criterion.final_mobile_urls",
+ "ad_group_criterion.final_urls",
+ "ad_group_criterion.negative",
+ "ad_group_criterion.placement.url",
+ "ad_group_criterion.status",
+ "ad_group_criterion.tracking_url_template",
+ "ad_group_criterion.url_custom_parameters",
+ "bidding_strategy.name",
+ "bidding_strategy.type",
+ "campaign.base_campaign",
+ "campaign.bidding_strategy",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.active_view_cpm",
+ "metrics.active_view_ctr",
+ "metrics.active_view_impressions",
+ "metrics.active_view_measurability",
+ "metrics.active_view_measurable_cost_micros",
+ "metrics.active_view_measurable_impressions",
+ "metrics.active_view_viewability",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.gmail_forwards",
+ "metrics.gmail_saves",
+ "metrics.gmail_secondary_clicks",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+]
+SEARCH_QUERY_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group_ad.ad.final_urls",
+ "ad_group_ad.ad.id",
+ "ad_group_ad.ad.tracking_url_template",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.absolute_top_impression_percentage",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpe",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.top_impression_percentage",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "search_term_view.search_term",
+ "search_term_view.status",
+ "segments.ad_network_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.keyword.ad_group_criterion",
+ "segments.keyword.info.text",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.search_term_match_type",
+ "segments.week",
+ "segments.year",
+]
+SHOPPING_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.descriptive_name",
+ "customer.id",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cpc",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.impressions",
+ "metrics.search_absolute_top_impression_share",
+ "metrics.search_click_share",
+ "metrics.search_impression_share",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.product_aggregator_id",
+ "segments.product_bidding_category_level1",
+ "segments.product_bidding_category_level2",
+ "segments.product_bidding_category_level3",
+ "segments.product_bidding_category_level4",
+ "segments.product_bidding_category_level5",
+ "segments.product_brand",
+ "segments.product_channel",
+ "segments.product_channel_exclusivity",
+ "segments.product_condition",
+ "segments.product_country",
+ "segments.product_custom_attribute0",
+ "segments.product_custom_attribute1",
+ "segments.product_custom_attribute2",
+ "segments.product_custom_attribute3",
+ "segments.product_custom_attribute4",
+ "segments.product_item_id",
+ "segments.product_language",
+ "segments.product_merchant_id",
+ "segments.product_store_id",
+ "segments.product_title",
+ "segments.product_type_l1",
+ "segments.product_type_l2",
+ "segments.product_type_l3",
+ "segments.product_type_l4",
+ "segments.product_type_l5",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+]
+USER_LOCATION_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cost",
+ "metrics.average_cpc",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_from_interactions_rate",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.impressions",
+ "metrics.interaction_event_types",
+ "metrics.interaction_rate",
+ "metrics.interactions",
+ "metrics.value_per_all_conversions",
+ "metrics.value_per_conversion",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.geo_target_city",
+ "segments.geo_target_metro",
+ "segments.geo_target_most_specific_location",
+ "segments.geo_target_region",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+ "user_location_view.country_criterion_id",
+ "user_location_view.targeting_location",
+]
+VIDEO_PERFORMANCE_REPORT_FIELDS = [
+ "ad_group.id",
+ "ad_group.name",
+ "ad_group.status",
+ "ad_group_ad.ad.id",
+ "ad_group_ad.status",
+ "campaign.id",
+ "campaign.name",
+ "campaign.status",
+ "customer.currency_code",
+ "customer.descriptive_name",
+ "customer.descriptive_name",
+ "customer.id",
+ "customer.time_zone",
+ "metrics.all_conversions",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_from_interactions_rate",
+ "metrics.all_conversions_value",
+ "metrics.average_cpm",
+ "metrics.average_cpv",
+ "metrics.clicks",
+ "metrics.conversions",
+ "metrics.conversions_value",
+ "metrics.cost_micros",
+ "metrics.cost_per_all_conversions",
+ "metrics.cost_per_conversion",
+ "metrics.cross_device_conversions",
+ "metrics.ctr",
+ "metrics.engagement_rate",
+ "metrics.engagements",
+ "metrics.impressions",
+ "metrics.value_per_all_conversions",
+ "metrics.video_quartile_p100_rate",
+ "metrics.video_quartile_p25_rate",
+ "metrics.video_quartile_p50_rate",
+ "metrics.video_quartile_p75_rate",
+ "metrics.video_view_rate",
+ "metrics.video_views",
+ "metrics.view_through_conversions",
+ "segments.ad_network_type",
+ "segments.click_type",
+ "segments.conversion_action",
+ "segments.conversion_action_category",
+ "segments.conversion_action_name",
+ "segments.date",
+ "segments.day_of_week",
+ "segments.device",
+ "segments.external_conversion_source",
+ "segments.month",
+ "segments.month_of_year",
+ "segments.quarter",
+ "segments.week",
+ "segments.year",
+ "video.channel_id",
+ "video.duration_millis",
+ "video.id",
+ "video.title",
+]
diff --git a/tap_google_ads/reports.py b/tap_google_ads/reports.py
deleted file mode 100644
index 2a02399..0000000
--- a/tap_google_ads/reports.py
+++ /dev/null
@@ -1,445 +0,0 @@
-from collections import defaultdict
-import json
-
-import singer
-from singer import Transformer
-
-from google.protobuf.json_format import MessageToJson
-
-from . import report_definitions
-
-LOGGER = singer.get_logger()
-
-API_VERSION = "v9"
-
-CORE_STREAMS = [
- "customer",
- "ad_group",
- "ad_group_ad",
- "campaign",
- "bidding_strategy",
- "accessible_bidding_strategy",
- "campaign_budget",
-]
-
-
-def flatten(obj):
- """Given an `obj` like
-
- {"a" : {"b" : "c"},
- "d": "e"}
-
- return
-
- {"a.b": "c",
- "d": "e"}
- """
- new_obj = {}
- for key, value in obj.items():
- if isinstance(value, dict):
- for sub_key, sub_value in flatten(value).items():
- new_obj[f"{key}.{sub_key}"] = sub_value
- else:
- new_obj[key] = value
- return new_obj
-
-
-def make_field_names(resource_name, fields):
- transformed_fields = []
- for field in fields:
- pieces = field.split("_")
- front = "_".join(pieces[:-1])
- back = pieces[-1]
-
- if '.' in field:
- transformed_fields.append(f"{resource_name}.{field}")
- elif front in CORE_STREAMS and field.endswith('_id'):
- transformed_fields.append(f"{front}.{back}")
- else:
- transformed_fields.append(f"{resource_name}.{field}")
- return transformed_fields
-
-
-def transform_keys(resource_name, flattened_obj):
- transformed_obj = {}
-
- for field, value in flattened_obj.items():
- resource_matches = field.startswith(resource_name + ".")
- is_id_field = field.endswith(".id")
-
- if resource_matches:
- new_field_name = ".".join(field.split(".")[1:])
- elif is_id_field:
- new_field_name = field.replace(".", "_")
- else:
- new_field_name = field
-
- assert new_field_name not in transformed_obj
- transformed_obj[new_field_name] = value
-
- return transformed_obj
-
-class BaseStream:
- def sync(self, sdk_client, customer, stream):
- gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
- resource_name = self.google_ads_resources_name[0]
- stream_name = stream["stream"]
- stream_mdata = stream["metadata"]
- selected_fields = []
- for mdata in stream_mdata:
- if (
- mdata["breadcrumb"]
- and mdata["metadata"].get("selected")
- and mdata["metadata"].get("inclusion") == "available"
- ):
- selected_fields.append(mdata["breadcrumb"][1])
-
- google_field_names = make_field_names(resource_name, selected_fields)
- query = f"SELECT {','.join(google_field_names)} FROM {resource_name}"
- response = gas.search(query=query, customer_id=customer["customerId"])
- with Transformer() as transformer:
- json_response = [
- json.loads(MessageToJson(x, preserving_proto_field_name=True))
- for x in response
- ]
- for obj in json_response:
- flattened_obj = flatten(obj)
- transformed_obj = transform_keys(resource_name, flattened_obj)
- record = transformer.transform(transformed_obj, stream["schema"])
- singer.write_record(stream_name, record)
-
- def add_extra_fields(self, resource_schema):
- """This function should add fields to `field_exclusions`, `schema`, and
- `behavior` that are not covered by Google's resource_schema
- """
-
- def extract_field_information(self, resource_schema):
- self.field_exclusions = defaultdict(set)
- self.schema = {}
- self.behavior = {}
- self.selectable = {}
-
- for resource_name in self.google_ads_resources_name:
-
- # field_exclusions step
- fields = resource_schema[resource_name]["fields"]
- for field_name, field in fields.items():
- if field_name in self.fields:
- self.field_exclusions[field_name].update(
- field["incompatible_fields"]
- )
-
- self.schema[field_name] = field["field_details"]["json_schema"]
-
- self.behavior[field_name] = field["field_details"]["category"]
-
- self.selectable[field_name] = field["field_details"]["selectable"]
- self.add_extra_fields(resource_schema)
- self.field_exclusions = {k: list(v) for k, v in self.field_exclusions.items()}
-
- def __init__(self, fields, google_ads_resource_name, resource_schema, primary_keys):
- self.fields = fields
- self.google_ads_resources_name = google_ads_resource_name
- self.primary_keys = primary_keys
- self.extract_field_information(resource_schema)
-
-
-class AdGroupPerformanceReport(BaseStream):
- def add_extra_fields(self, resource_schema):
- # from the resource ad_group_ad_label
- field_name = "label.resource_name"
- # for field_name in []:
- self.field_exclusions[field_name] = {}
- self.schema[field_name] = {"type": ["null", "string"]}
- self.behavior[field_name] = "ATTRIBUTE"
-
-
-class AdPerformanceReport(BaseStream):
- def add_extra_fields(self, resource_schema):
- # from the resource ad_group_ad_label
- for field_name in ["label.resource_name", "label.name"]:
- self.field_exclusions[field_name] = {}
- self.schema[field_name] = {"type": ["null", "string"]}
- self.behavior[field_name] = "ATTRIBUTE"
-
- for field_name in [
- "ad_group_criterion.negative",
- ]:
- self.field_exclusions[field_name] = {}
- self.schema[field_name] = {"type": ["null", "boolean"]}
- self.behavior[field_name] = "ATTRIBUTE"
-
-
-class AudiencePerformanceReport(BaseStream):
- "hi"
- # COMMENT FROM GOOGLE
- #'bidding_strategy.name must be selected withy the resources bidding_strategy or campaign.',
-
- # We think this means
- # `SELECT bidding_strategy.name from bidding_strategy`
- # Not sure how this applies to the campaign resource
-
- # COMMENT FROM GOOGLE
- # 'campaign.bidding_strategy.type must be selected withy the resources bidding_strategy or campaign.'
-
- # We think this means
- # `SELECT bidding_strategy.type from bidding_strategy`
-
- # `SELECT campaign.bidding_strategy_type from campaign`
-
- # 'user_list.name' is a "Segmenting resource"
- # `select user_list.name from `
-
-class CampaignPerformanceReport(BaseStream):
- # TODO: The sync needs to select from campaign_criterion if campaign_criterion.device.type is selected
- # TODO: The sync needs to select from campaign_label if label.resource_name
- def add_extra_fields(self, resource_schema):
- for field_name in [
- "campaign_criterion.device.type",
- "label.resource_name",
- ]:
- self.field_exclusions[field_name] = set()
- self.schema[field_name] = {"type": ["null", "string"]}
- self.behavior[field_name] = "ATTRIBUTE"
-
-
-class DisplayKeywordPerformanceReport(BaseStream):
- # TODO: The sync needs to select from bidding_strategy and/or campaign if bidding_strategy.name is selected
- def add_extra_fields(self, resource_schema):
- for field_name in [
- "bidding_strategy.name",
- ]:
- self.field_exclusions[field_name] = resource_schema[
- self.google_ads_resources_name[0]
- ]["fields"][field_name]["incompatible_fields"]
- self.schema[field_name] = {"type": ["null", "string"]}
- self.behavior[field_name] = "SEGMENT"
-
-
-class GeoPerformanceReport(BaseStream):
- # TODO: The sync needs to select from bidding_strategy and/or campaign if bidding_strategy.name is selected
- def add_extra_fields(self, resource_schema):
- for resource_name in self.google_ads_resources_name:
- for field_name in [
- "country_criterion_id",
- ]:
- full_field_name = f"{resource_name}.{field_name}"
- self.field_exclusions[full_field_name] = (
- resource_schema[resource_name]["fields"][full_field_name][
- "incompatible_fields"
- ]
- or set()
- )
- self.schema[full_field_name] = {"type": ["null", "string"]}
- self.behavior[full_field_name] = "ATTRIBUTE"
-
-
-class KeywordsPerformanceReport(BaseStream):
- # TODO: The sync needs to select from ad_group_label if label.name is selected
- # TODO: The sync needs to select from ad_group_label if label.resource_name is selected
- def add_extra_fields(self, resource_schema):
- for field_name in [
- "label.resource_name",
- "label.name",
- ]:
- self.field_exclusions[field_name] = set()
- self.schema[field_name] = {"type": ["null", "string"]}
- self.behavior[field_name] = "ATTRIBUTE"
-
-
-class PlaceholderFeedItemReport(BaseStream):
- # TODO: The sync needs to select from feed_item_target if feed_item_target.device is selected
- # TODO: The sync needs to select from feed_item if feed_item.policy_infos is selected
- def add_extra_fields(self, resource_schema):
- for field_name in ["feed_item_target.device", "feed_item.policy_infos"]:
- self.field_exclusions[field_name] = set()
- self.schema[field_name] = {"type": ["null", "string"]}
- self.behavior[field_name] = "ATTRIBUTE"
-
-
-def initialize_core_streams(resource_schema):
- return {
- "accounts": BaseStream(
- report_definitions.ACCOUNT_FIELDS,
- ["customer"],
- resource_schema,
- ["customer.id"],
- ),
- "ad_groups": BaseStream(
- report_definitions.AD_GROUP_FIELDS,
- ["ad_group"],
- resource_schema,
- ["ad_group.id"],
- ),
- "ads": BaseStream(
- report_definitions.AD_GROUP_AD_FIELDS,
- ["ad_group_ad"],
- resource_schema,
- ["ad_group_ad.ad.id"],
- ),
- "campaigns": BaseStream(
- report_definitions.CAMPAIGN_FIELDS,
- ["campaign"],
- resource_schema,
- ["campaign.id"],
- ),
- "bidding_strategies": BaseStream(
- report_definitions.BIDDING_STRATEGY_FIELDS,
- ["bidding_strategy"],
- resource_schema,
- ["bidding_strategy.id"],
- ),
- "accessible_bidding_strategies": BaseStream(
- report_definitions.ACCESSIBLE_BIDDING_STRATEGY_FIELDS,
- ["accessible_bidding_strategy"],
- resource_schema,
- ["accessible_bidding_strategy.id"],
- ),
- "campaign_budgets": BaseStream(
- report_definitions.CAMPAIGN_BUDGET_FIELDS,
- ["campaign_budget"],
- resource_schema,
- ["campaign_budget.id"],
- ),
- }
-
-
-def initialize_reports(resource_schema):
- return {
- "account_performance_report": BaseStream(
- report_definitions.ACCOUNT_PERFORMANCE_REPORT_FIELDS,
- ["customer"],
- resource_schema,
- ["customer.id"],
- ),
- # TODO: This needs to link with ad_group_ad_label
- "adgroup_performance_report": AdGroupPerformanceReport(
- report_definitions.ADGROUP_PERFORMANCE_REPORT_FIELDS,
- ["ad_group"],
- resource_schema,
- ["ad_group.id"],
- ),
- "ad_performance_report": AdPerformanceReport(
- report_definitions.AD_PERFORMANCE_REPORT_FIELDS,
- ["ad_group_ad"],
- resource_schema,
- ["ad_group_ad.ad.id"],
- ),
- "age_range_performance_report": BaseStream(
- report_definitions.AGE_RANGE_PERFORMANCE_REPORT_FIELDS,
- ["age_range_view"],
- resource_schema,
- ["ad_group_criterion.criterion_id"],
- ),
- "audience_performance_report": AudiencePerformanceReport(
- report_definitions.AUDIENCE_PERFORMANCE_REPORT_FIELDS,
- ["campaign_audience_view", "ad_group_audience_view"],
- resource_schema,
- ["ad_group_criterion.criterion_id"],
- ),
- "call_metrics_call_details_report": BaseStream(
- report_definitions.CALL_METRICS_CALL_DETAILS_REPORT_FIELDS,
- ["call_view"],
- resource_schema,
- [""],
- ),
- "campaign_performance_report": CampaignPerformanceReport(
- report_definitions.CAMPAIGN_PERFORMANCE_REPORT_FIELDS,
- ["campaign"],
- resource_schema,
- [""],
- ),
- "click_performance_report": BaseStream(
- report_definitions.CLICK_PERFORMANCE_REPORT_FIELDS,
- ["click_view"],
- resource_schema,
- [""],
- ),
- "display_keyword_performance_report": DisplayKeywordPerformanceReport(
- report_definitions.DISPLAY_KEYWORD_PERFORMANCE_REPORT_FIELDS,
- ["display_keyword_view"],
- resource_schema,
- ["ad_group_criterion.criterion_id"],
- ),
- "display_topics_performance_report": DisplayKeywordPerformanceReport(
- report_definitions.DISPLAY_TOPICS_PERFORMANCE_REPORT_FIELDS,
- ["topic_view"],
- resource_schema,
- [""],
- ),
- "gender_performance_report": BaseStream(
- report_definitions.GENDER_PERFORMANCE_REPORT_FIELDS,
- ["gender_view"],
- resource_schema,
- [""],
- ),
- "geo_performance_report": GeoPerformanceReport(
- report_definitions.GEO_PERFORMANCE_REPORT_FIELDS,
- ["geographic_view", "user_location_view"],
- resource_schema,
- [""],
- ),
- "keywordless_query_report": BaseStream(
- report_definitions.KEYWORDLESS_QUERY_REPORT_FIELDS,
- ["dynamic_search_ads_search_term_view"],
- resource_schema,
- [""],
- ),
- "keywords_performance_report": KeywordsPerformanceReport(
- report_definitions.KEYWORDS_PERFORMANCE_REPORT_FIELDS,
- ["keyword_view"],
- resource_schema,
- [""],
- ),
- "placeholder_feed_item_report": PlaceholderFeedItemReport(
- report_definitions.PLACEHOLDER_FEED_ITEM_REPORT_FIELDS,
- ["feed_item", "feed_item_target"],
- resource_schema,
- [""],
- ),
- "placeholder_report": BaseStream(
- report_definitions.PLACEHOLDER_REPORT_FIELDS,
- ["feed_placeholder_view"],
- resource_schema,
- [""],
- ),
- "placement_performance_report": BaseStream(
- report_definitions.PLACEMENT_PERFORMANCE_REPORT_FIELDS,
- ["managed_placement_view"],
- resource_schema,
- [""],
- ),
- "search_query_performance_report": BaseStream(
- report_definitions.SEARCH_QUERY_PERFORMANCE_REPORT_FIELDS,
- ["search_term_view"],
- resource_schema,
- [""],
- ),
- "shopping_performance_report": BaseStream(
- report_definitions.SHOPPING_PERFORMANCE_REPORT_FIELDS,
- ["shopping_performance_view"],
- resource_schema,
- [""],
- ),
- "video_performance_report": BaseStream(
- report_definitions.VIDEO_PERFORMANCE_REPORT_FIELDS,
- ["video"],
- resource_schema,
- [""],
- ),
- # "automatic_placements_performance_report": BaseStream(report_definitions.AUTOMATIC_PLACEMENTS_PERFORMANCE_REPORT_FIELDS, ["group_placement_view"], resource_schema),
- # "bid_goal_performance_report": BaseStream(report_definitions.BID_GOAL_PERFORMANCE_REPORT_FIELDS, ["bidding_strategy"], resource_schema),
- # "budget_performance_report": BaseStream(report_definitions.BUDGET_PERFORMANCE_REPORT_FIELDS, ["campaign_budget"], resource_schema),
- # "campaign_ad_schedule_target_report": BaseStream(report_definitions.CAMPAIGN_AD_SCHEDULE_TARGET_REPORT_FIELDS, ["ad_schedule_view"], resource_schema),
- # "campaign_criteria_report": BaseStream(report_definitions.CAMPAIGN_CRITERIA_REPORT_FIELDS, ["campaign_criterion"], resource_schema),
- # "campaign_location_target_report": BaseStream(report_definitions.CAMPAIGN_LOCATION_TARGET_REPORT_FIELDS, ["location_view"], resource_schema),
- # "campaign_shared_set_report": BaseStream(report_definitions.CAMPAIGN_SHARED_SET_REPORT_FIELDS, ["campaign_shared_set"], resource_schema),
- # "label_report": BaseStream(report_definitions.LABEL_REPORT_FIELDS, ["label"], resource_schema),
- # "landing_page_report": BaseStream(report_definitions.LANDING_PAGE_REPORT_FIELDS, ["landing_page_view", "expanded_landing_page_view"], resource_schema),
- # "paid_organic_query_report": BaseStream(report_definitions.PAID_ORGANIC_QUERY_REPORT_FIELDS, ["paid_organic_search_term_view"], resource_schema),
- # "parental_status_performance_report": BaseStream(report_definitions.PARENTAL_STATUS_PERFORMANCE_REPORT_FIELDS, ["parental_status_view"], resource_schema),
- # "product_partition_report": BaseStream(report_definitions.PRODUCT_PARTITION_REPORT_FIELDS, ["product_group_view"], resource_schema),
- # "shared_set_criteria_report": BaseStream(report_definitions.SHARED_SET_CRITERIA_REPORT_FIELDS, ["shared_criterion"], resource_schema),
- # "url_performance_report": BaseStream(report_definitions.URL_PERFORMANCE_REPORT_FIELDS, ["detail_placement_view"], resource_schema),
- # "user_ad_distance_report": BaseStream(report_definitions.USER_AD_DISTANCE_REPORT_FIELDS, ["distance_view"], resource_schema),
- }
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
new file mode 100644
index 0000000..09c3aad
--- /dev/null
+++ b/tap_google_ads/streams.py
@@ -0,0 +1,620 @@
+from collections import defaultdict
+import json
+import hashlib
+from datetime import timedelta
+import singer
+from singer import Transformer
+from singer import utils
+from google.protobuf.json_format import MessageToJson
+from . import report_definitions
+
+LOGGER = singer.get_logger()
+
+API_VERSION = "v9"
+
+REPORTS_WITH_90_DAY_MAX = frozenset(
+ [
+ "click_performance_report",
+ ]
+)
+
+DEFAULT_CONVERSION_WINDOW = 30
+
+
+def create_nested_resource_schema(resource_schema, fields):
+ new_schema = {
+ "type": ["null", "object"],
+ "properties": {}
+ }
+
+ for field in fields:
+ walker = new_schema["properties"]
+ paths = field.split(".")
+ last_path = paths[-1]
+ for path in paths[:-1]:
+ if path not in walker:
+ walker[path] = {
+ "type": ["null", "object"],
+ "properties": {}
+ }
+ walker = walker[path]["properties"]
+ if last_path not in walker:
+ json_schema = resource_schema[field]["json_schema"]
+ walker[last_path] = json_schema
+ return new_schema
+
+
+def get_selected_fields(stream_mdata):
+ selected_fields = set()
+ for mdata in stream_mdata:
+ if mdata["breadcrumb"]:
+ inclusion = mdata["metadata"].get("inclusion")
+ selected = mdata["metadata"].get("selected")
+ if utils.should_sync_field(inclusion, selected) and mdata["breadcrumb"][1] != "_sdc_record_hash":
+ selected_fields.update(mdata["metadata"]["tap-google-ads.api-field-names"])
+
+ return selected_fields
+
+
+def create_core_stream_query(resource_name, selected_fields):
+ core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name}"
+ return core_query
+
+
+def create_report_query(resource_name, selected_fields, query_date):
+
+ format_str = "%Y-%m-%d"
+ query_date = utils.strftime(query_date, format_str=format_str)
+ report_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} WHERE segments.date = '{query_date}'"
+
+ return report_query
+
+
+def generate_hash(record, metadata):
+ metadata = singer.metadata.to_map(metadata)
+ fields_to_hash = {}
+ for key, val in record.items():
+ if metadata[("properties", key)]["behavior"] != "METRIC":
+ fields_to_hash[key] = val
+
+ hash_source_data = {key: fields_to_hash[key] for key in sorted(fields_to_hash)}
+ hash_bytes = json.dumps(hash_source_data).encode("utf-8")
+ return hashlib.sha256(hash_bytes).hexdigest()
+
+
+class BaseStream: # pylint: disable=too-many-instance-attributes
+
+ def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys):
+ self.fields = fields
+ self.google_ads_resource_names = google_ads_resource_names
+ self.primary_keys = primary_keys
+
+ self.extract_field_information(resource_schema)
+
+ self.create_full_schema(resource_schema)
+ self.set_stream_schema()
+ self.format_field_names()
+
+ self.build_stream_metadata()
+
+
+ def extract_field_information(self, resource_schema):
+ self.field_exclusions = defaultdict(set)
+ self.schema = {}
+ self.behavior = {}
+ self.selectable = {}
+
+ for resource_name in self.google_ads_resource_names:
+
+ # field_exclusions step
+ fields = resource_schema[resource_name]["fields"]
+ for field_name, field in fields.items():
+ if field_name in self.fields:
+ self.field_exclusions[field_name].update(
+ field["incompatible_fields"]
+ )
+
+ self.schema[field_name] = field["field_details"]["json_schema"]
+
+ self.behavior[field_name] = field["field_details"]["category"]
+
+ self.selectable[field_name] = field["field_details"]["selectable"]
+ self.add_extra_fields(resource_schema)
+ self.field_exclusions = {k: list(v) for k, v in self.field_exclusions.items()}
+
+ def add_extra_fields(self, resource_schema):
+ """This function should add fields to `field_exclusions`, `schema`, and
+ `behavior` that are not covered by Google's resource_schema
+ """
+
+ def create_full_schema(self, resource_schema):
+ google_ads_name = self.google_ads_resource_names[0]
+ self.resource_object = resource_schema[google_ads_name]
+ self.resource_fields = self.resource_object["fields"]
+ self.full_schema = create_nested_resource_schema(resource_schema, self.resource_fields)
+
+ def set_stream_schema(self):
+ google_ads_name = self.google_ads_resource_names[0]
+ self.stream_schema = self.full_schema["properties"][google_ads_name]
+
+ def format_field_names(self):
+ """This function does two things:
+ 1. Appends a `resource_name` to an id field if it is the id of an attributed resource
+ 2. Lifts subfields of `ad_group_ad.ad` into `ad_group_ad`
+ """
+ for resource_name, schema in self.full_schema["properties"].items():
+ # ads stream is special since all of the ad fields are nested under ad_group_ad.ad
+ # we need to bump the fields up a level so they are selectable
+ if resource_name == "ad_group_ad":
+ for ad_field_name, ad_field_schema in self.full_schema["properties"]["ad_group_ad"]["properties"]["ad"]["properties"].items():
+ self.stream_schema["properties"][ad_field_name] = ad_field_schema
+ self.stream_schema["properties"].pop("ad")
+
+ if (
+ resource_name not in {"metrics", "segments"}
+ and resource_name not in self.google_ads_resource_names
+ ):
+ self.stream_schema["properties"][resource_name + "_id"] = schema["properties"]["id"]
+
+ def build_stream_metadata(self):
+ self.stream_metadata = {
+ (): {
+ "inclusion": "available",
+ "forced-replication-method": "FULL_TABLE",
+ "table-key-properties": self.primary_keys,
+ }
+ }
+
+ for field, props in self.resource_fields.items():
+ resource_matches = field.startswith(self.resource_object["name"] + ".")
+ is_id_field = field.endswith(".id")
+
+ if is_id_field or (props["field_details"]["category"] == "ATTRIBUTE" and resource_matches):
+ # Transform the field name to match the schema
+ # Special case for ads since they are nested under ad_group_ad and
+ # we have to bump them up a level
+ if field.startswith("ad_group_ad.ad."):
+ field = field.split(".")[2]
+ else:
+ if resource_matches:
+ field = field.split(".")[1]
+ elif is_id_field:
+ field = field.replace(".", "_")
+
+ if ("properties", field) not in self.stream_metadata:
+ # Base metadata for every field
+ self.stream_metadata[("properties", field)] = {
+ "fieldExclusions": props["incompatible_fields"],
+ "behavior": props["field_details"]["category"],
+ }
+
+ # Add inclusion metadata
+ # Foreign keys are automatically included and they are all id fields
+ if field in self.primary_keys or field in {'customer_id', 'ad_group_id', 'campaign_id'}:
+ inclusion = "automatic"
+ elif props["field_details"]["selectable"]:
+ inclusion = "available"
+ else:
+ # inclusion = "unsupported"
+ continue
+ self.stream_metadata[("properties", field)]["inclusion"] = inclusion
+
+ # Save the full field name for sync code to use
+ full_name = props["field_details"]["name"]
+ if "tap-google-ads.api-field-names" not in self.stream_metadata[("properties", field)]:
+ self.stream_metadata[("properties", field)]["tap-google-ads.api-field-names"] = []
+
+ if props["field_details"]["selectable"]:
+ self.stream_metadata[("properties", field)]["tap-google-ads.api-field-names"].append(full_name)
+
+ def transform_keys(self, obj):
+ """This function does a few things with Google's response for sync queries:
+ 1) checks an object's fields to see if they're for the current resource
+ 2) if they are, keep the fields in transformed_obj with no modifications
+ 3) if they are not, append a foreign key to the transformed_obj using the id value
+ 4) if the resource is ad_group_ad, pops ad fields up to the ad_group_ad level
+
+ We've seen API responses where Google returns `type_` when the
+ field we ask for is `type`, so we transfrom the key-value pair
+ `"type_": X` to `"type": X`
+ """
+ target_resource_name = self.google_ads_resource_names[0]
+ transformed_obj = {}
+
+ for resource_name, value in obj.items():
+ resource_matches = target_resource_name == resource_name
+
+ if resource_matches:
+ transformed_obj.update(value)
+ else:
+ transformed_obj[f"{resource_name}_id"] = value["id"]
+
+ if resource_name == "ad_group_ad":
+ transformed_obj.update(value["ad"])
+ transformed_obj.pop("ad")
+
+ if "type_" in transformed_obj:
+ transformed_obj["type"] = transformed_obj.pop("type_")
+
+ return transformed_obj
+
+ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=unused-argument
+ gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
+ resource_name = self.google_ads_resource_names[0]
+ stream_name = stream["stream"]
+ stream_mdata = stream["metadata"]
+ selected_fields = get_selected_fields(stream_mdata)
+ state = singer.set_currently_syncing(state, stream_name)
+ LOGGER.info(f"Selected fields for stream {stream_name}: {selected_fields}")
+
+ query = create_core_stream_query(resource_name, selected_fields)
+ response = gas.search(query=query, customer_id=customer["customerId"])
+ with Transformer() as transformer:
+ # Pages are fetched automatically while iterating through the response
+ for message in response:
+ json_message = json.loads(MessageToJson(message, preserving_proto_field_name=True))
+ transformed_obj = self.transform_keys(json_message)
+ record = transformer.transform(transformed_obj, stream["schema"], singer.metadata.to_map(stream_mdata))
+
+ singer.write_record(stream_name, record)
+
+ state = singer.bookmarks.set_currently_syncing(state, None)
+
+
+def get_query_date(start_date, bookmark, conversion_window_date):
+ """Return a date within the conversion window and after start date
+
+ All inputs are datetime strings.
+ NOTE: `bookmark` may be None"""
+ if not bookmark:
+ return singer.utils.strptime_to_utc(start_date)
+ else:
+ query_date = min(bookmark, max(start_date, conversion_window_date))
+ return singer.utils.strptime_to_utc(query_date)
+
+
+class ReportStream(BaseStream):
+ def create_full_schema(self, resource_schema):
+ google_ads_name = self.google_ads_resource_names[0]
+ self.resource_object = resource_schema[google_ads_name]
+ self.resource_fields = self.resource_object["fields"]
+ self.full_schema = create_nested_resource_schema(resource_schema, self.fields)
+
+ def set_stream_schema(self):
+ self.stream_schema = {
+ "type": ["null", "object"],
+ "is_report": True,
+ "properties": {
+ "_sdc_record_hash": {"type": "string"}
+ },
+ }
+
+ def format_field_names(self):
+ """This function does two things right now:
+ 1. Appends a `resource_name` to a field name if the field is in an attributed resource
+ 2. Lifts subfields of `ad_group_ad.ad` into `ad_group_ad`
+ """
+ for resource_name, schema in self.full_schema["properties"].items():
+ for field_name, data_type in schema["properties"].items():
+ # Ensure that attributed resource fields have the resource name as a prefix, eg campaign_id under the ad_groups stream
+ if resource_name not in {"metrics", "segments"} and resource_name not in self.google_ads_resource_names:
+ self.stream_schema["properties"][f"{resource_name}_{field_name}"] = data_type
+ # Move ad_group_ad.ad.x fields up a level in the schema (ad_group_ad.ad.x -> ad_group_ad.x)
+ elif resource_name == "ad_group_ad" and field_name == "ad":
+ for ad_field_name, ad_field_schema in data_type["properties"].items():
+ self.stream_schema["properties"][ad_field_name] = ad_field_schema
+ else:
+ self.stream_schema["properties"][field_name] = data_type
+
+ def build_stream_metadata(self):
+ self.stream_metadata = {
+ (): {
+ "inclusion": "available",
+ "table-key-properties": ["_sdc_record_hash"],
+ "forced-replication-method": "INCREMENTAL",
+ "valid-replication-keys": ["date"]
+ },
+ ("properties", "_sdc_record_hash"): {
+ "inclusion": "automatic"
+ },
+ }
+ for report_field in self.fields:
+ # Transform the field name to match the schema
+ is_metric_or_segment = report_field.startswith("metrics.") or report_field.startswith("segments.")
+ if (not is_metric_or_segment
+ and report_field.split(".")[0] not in self.google_ads_resource_names
+ ):
+ transformed_field_name = "_".join(report_field.split(".")[:2])
+ # Transform ad_group_ad.ad.x fields to just x to reflect ad_group_ads schema
+ elif report_field.startswith("ad_group_ad.ad."):
+ transformed_field_name = report_field.split(".")[2]
+ else:
+ transformed_field_name = report_field.split(".")[1]
+
+ # Base metadata for every field
+ if ("properties", transformed_field_name) not in self.stream_metadata:
+ self.stream_metadata[("properties", transformed_field_name)] = {
+ "fieldExclusions": [],
+ "behavior": self.behavior[report_field],
+ }
+
+ # Transform field exclusion names so they match the schema
+ for field_name in self.field_exclusions[report_field]:
+ is_metric_or_segment = field_name.startswith("metrics.") or field_name.startswith("segments.")
+ if (not is_metric_or_segment
+ and field_name.split(".")[0] not in self.google_ads_resource_names
+ ):
+ new_field_name = field_name.replace(".", "_")
+ else:
+ new_field_name = field_name.split(".")[1]
+
+ self.stream_metadata[("properties", transformed_field_name)]["fieldExclusions"].append(new_field_name)
+
+ # Add inclusion metadata
+ if self.behavior[report_field]:
+ inclusion = "available"
+ if report_field == "segments.date":
+ inclusion = "automatic"
+ else:
+ inclusion = "unsupported"
+ self.stream_metadata[("properties", transformed_field_name)]["inclusion"] = inclusion
+
+ # Save the full field name for sync code to use
+ if "tap-google-ads.api-field-names" not in self.stream_metadata[("properties", transformed_field_name)]:
+ self.stream_metadata[("properties", transformed_field_name)]["tap-google-ads.api-field-names"] = []
+
+ self.stream_metadata[("properties", transformed_field_name)]["tap-google-ads.api-field-names"].append(report_field)
+
+ def transform_keys(self, obj):
+ transformed_obj = {}
+
+ for resource_name, value in obj.items():
+ if resource_name == "ad_group_ad":
+ transformed_obj.update(value["ad"])
+ else:
+ transformed_obj.update(value)
+
+ if "type_" in transformed_obj:
+ transformed_obj["type"] = transformed_obj.pop("type_")
+
+ return transformed_obj
+
+ def sync(self, sdk_client, customer, stream, config, state):
+ gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
+ resource_name = self.google_ads_resource_names[0]
+ stream_name = stream["stream"]
+ stream_mdata = stream["metadata"]
+ selected_fields = get_selected_fields(stream_mdata)
+ replication_key = "date"
+ state = singer.set_currently_syncing(state, stream_name)
+ conversion_window = timedelta(
+ days=int(config.get("conversion_window") or DEFAULT_CONVERSION_WINDOW)
+ )
+ conversion_window_date = utils.now() - conversion_window
+
+ query_date = get_query_date(
+ start_date=config["start_date"],
+ bookmark=singer.get_bookmark(state, stream_name, replication_key),
+ conversion_window_date=singer.utils.strftime(conversion_window_date)
+ )
+ end_date = utils.now()
+
+ if stream_name in REPORTS_WITH_90_DAY_MAX:
+ cutoff = end_date - timedelta(days=90)
+ query_date = max(query_date, cutoff)
+ if query_date == cutoff:
+ LOGGER.info(f"Stream: {stream_name} supports only 90 days of data. Setting query date to {utils.strftime(query_date, '%Y-%m-%d')}.")
+
+ LOGGER.info(f"Selected fields for stream {stream_name}: {selected_fields}")
+ singer.write_state(state)
+
+ if selected_fields == {'segments.date'}:
+ raise Exception(f"Selected fields is currently limited to {', '.join(selected_fields)}. Please select at least one attribute and metric in order to replicate {stream_name}.")
+
+ while query_date < end_date:
+ query = create_report_query(resource_name, selected_fields, query_date)
+ LOGGER.info(f"Requesting {stream_name} data for {utils.strftime(query_date, '%Y-%m-%d')}.")
+ response = gas.search(query=query, customer_id=customer["customerId"])
+
+ with Transformer() as transformer:
+ # Pages are fetched automatically while iterating through the response
+ for message in response:
+ json_message = json.loads(MessageToJson(message, preserving_proto_field_name=True))
+ transformed_obj = self.transform_keys(json_message)
+ record = transformer.transform(transformed_obj, stream["schema"])
+ record["_sdc_record_hash"] = generate_hash(record, stream_mdata)
+
+ singer.write_record(stream_name, record)
+
+ singer.write_bookmark(state, stream_name, replication_key, utils.strftime(query_date))
+
+ singer.write_state(state)
+
+ query_date += timedelta(days=1)
+
+ state = singer.bookmarks.set_currently_syncing(state, None)
+ singer.write_state(state)
+
+
+def initialize_core_streams(resource_schema):
+ return {
+ "accounts": BaseStream(
+ report_definitions.ACCOUNT_FIELDS,
+ ["customer"],
+ resource_schema,
+ ["id"],
+ ),
+ "ad_groups": BaseStream(
+ report_definitions.AD_GROUP_FIELDS,
+ ["ad_group"],
+ resource_schema,
+ ["id"],
+ ),
+ "ads": BaseStream(
+ report_definitions.AD_GROUP_AD_FIELDS,
+ ["ad_group_ad"],
+ resource_schema,
+ ["id"],
+ ),
+ "campaigns": BaseStream(
+ report_definitions.CAMPAIGN_FIELDS,
+ ["campaign"],
+ resource_schema,
+ ["id"],
+ ),
+ "bidding_strategies": BaseStream(
+ report_definitions.BIDDING_STRATEGY_FIELDS,
+ ["bidding_strategy"],
+ resource_schema,
+ ["id"],
+ ),
+ "accessible_bidding_strategies": BaseStream(
+ report_definitions.ACCESSIBLE_BIDDING_STRATEGY_FIELDS,
+ ["accessible_bidding_strategy"],
+ resource_schema,
+ ["id"],
+ ),
+ "campaign_budgets": BaseStream(
+ report_definitions.CAMPAIGN_BUDGET_FIELDS,
+ ["campaign_budget"],
+ resource_schema,
+ ["id"],
+ ),
+ }
+
+
+def initialize_reports(resource_schema):
+ return {
+ "account_performance_report": ReportStream(
+ report_definitions.ACCOUNT_PERFORMANCE_REPORT_FIELDS,
+ ["customer"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "adgroup_performance_report": ReportStream(
+ report_definitions.ADGROUP_PERFORMANCE_REPORT_FIELDS,
+ ["ad_group"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "ad_performance_report": ReportStream(
+ report_definitions.AD_PERFORMANCE_REPORT_FIELDS,
+ ["ad_group_ad"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "age_range_performance_report": ReportStream(
+ report_definitions.AGE_RANGE_PERFORMANCE_REPORT_FIELDS,
+ ["age_range_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "audience_performance_report": ReportStream(
+ report_definitions.AD_GROUP_AUDIENCE_PERFORMANCE_REPORT_FIELDS,
+ ["ad_group_audience_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "campaign_performance_report": ReportStream(
+ report_definitions.CAMPAIGN_PERFORMANCE_REPORT_FIELDS,
+ ["campaign"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "click_performance_report": ReportStream(
+ report_definitions.CLICK_PERFORMANCE_REPORT_FIELDS,
+ ["click_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "display_keyword_performance_report": ReportStream(
+ report_definitions.DISPLAY_KEYWORD_PERFORMANCE_REPORT_FIELDS,
+ ["display_keyword_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "display_topics_performance_report": ReportStream(
+ report_definitions.DISPLAY_TOPICS_PERFORMANCE_REPORT_FIELDS,
+ ["topic_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "expanded_landing_page_report": ReportStream(
+ report_definitions.EXPANDED_LANDING_PAGE_REPORT_FIELDS,
+ ["expanded_landing_page_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "gender_performance_report": ReportStream(
+ report_definitions.GENDER_PERFORMANCE_REPORT_FIELDS,
+ ["gender_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "geo_performance_report": ReportStream(
+ report_definitions.GEO_PERFORMANCE_REPORT_FIELDS,
+ ["geographic_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "keywordless_query_report": ReportStream(
+ report_definitions.KEYWORDLESS_QUERY_REPORT_FIELDS,
+ ["dynamic_search_ads_search_term_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "keywords_performance_report": ReportStream(
+ report_definitions.KEYWORDS_PERFORMANCE_REPORT_FIELDS,
+ ["keyword_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "landing_page_report": ReportStream(
+ report_definitions.LANDING_PAGE_REPORT_FIELDS,
+ ["landing_page_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "placeholder_feed_item_report": ReportStream(
+ report_definitions.PLACEHOLDER_FEED_ITEM_REPORT_FIELDS,
+ ["feed_item"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "placeholder_report": ReportStream(
+ report_definitions.PLACEHOLDER_REPORT_FIELDS,
+ ["feed_placeholder_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "placement_performance_report": ReportStream(
+ report_definitions.PLACEMENT_PERFORMANCE_REPORT_FIELDS,
+ ["managed_placement_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "search_query_performance_report": ReportStream(
+ report_definitions.SEARCH_QUERY_PERFORMANCE_REPORT_FIELDS,
+ ["search_term_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "shopping_performance_report": ReportStream(
+ report_definitions.SHOPPING_PERFORMANCE_REPORT_FIELDS,
+ ["shopping_performance_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "user_location_performance_report": ReportStream(
+ report_definitions.USER_LOCATION_PERFORMANCE_REPORT_FIELDS,
+ ["user_location_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ "video_performance_report": ReportStream(
+ report_definitions.VIDEO_PERFORMANCE_REPORT_FIELDS,
+ ["video"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
+ }
diff --git a/tap_google_ads/sync.py b/tap_google_ads/sync.py
new file mode 100644
index 0000000..1628ad8
--- /dev/null
+++ b/tap_google_ads/sync.py
@@ -0,0 +1,45 @@
+import json
+
+import singer
+
+from tap_google_ads.client import create_sdk_client
+from tap_google_ads.streams import initialize_core_streams, initialize_reports
+
+LOGGER = singer.get_logger()
+
+
+def do_sync(config, catalog, resource_schema, state):
+ # QA ADDED WORKAROUND [START]
+ try:
+ customers = json.loads(config["login_customer_ids"])
+ except TypeError: # falling back to raw value
+ customers = config["login_customer_ids"]
+ # QA ADDED WORKAROUND [END]
+
+ selected_streams = [
+ stream
+ for stream in catalog["streams"]
+ if singer.metadata.to_map(stream["metadata"])[()].get("selected")
+ ]
+
+ core_streams = initialize_core_streams(resource_schema)
+ report_streams = initialize_reports(resource_schema)
+
+ for customer in customers:
+ LOGGER.info(f"Syncing customer Id {customer['customerId']} ...")
+ sdk_client = create_sdk_client(config, customer["loginCustomerId"])
+ for catalog_entry in selected_streams:
+ stream_name = catalog_entry["stream"]
+ mdata_map = singer.metadata.to_map(catalog_entry["metadata"])
+
+ primary_key = mdata_map[()].get("table-key-properties", [])
+ singer.messages.write_schema(stream_name, catalog_entry["schema"], primary_key)
+
+ LOGGER.info(f"Syncing {stream_name} for customer Id {customer['customerId']}.")
+
+ if core_streams.get(stream_name):
+ stream_obj = core_streams[stream_name]
+ else:
+ stream_obj = report_streams[stream_name]
+
+ stream_obj.sync(sdk_client, customer, catalog_entry, config, state)
diff --git a/tests/base.py b/tests/base.py
index 59708bd..1990f6d 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -44,21 +44,13 @@ def get_type():
def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
- 'start_date': '2020-12-01T00:00:00Z',
- 'user_id': 'not used?',
+ 'start_date': '2021-12-01T00:00:00Z',
+ 'user_id': 'not used?', # TODO ?
'customer_ids': '5548074409,2728292456',
- 'login_customer_ids': [
- {
- "customerId": "5548074409",
- "loginCustomerId": "2728292456",
- },
- {
- "customerId": "2728292456",
- "loginCustomerId": "2728292456",
- },
- ],
+ # 'conversion_window_days': '30',
+ 'login_customer_ids': [{"customerId": "5548074409", "loginCustomerId": "2728292456",}],
}
-
+ # TODO_TDL-17911 Add a test around conversion_window_days
if original:
return return_value
@@ -73,7 +65,9 @@ def get_credentials(self):
def expected_metadata(self):
"""The expected streams and metadata about the streams"""
-
+ # TODO Investigate the foreign key expectations here,
+ # - must prove each uncommented entry is a true foregin key constraint.
+ # - must prove each commented entry is a NOT true foregin key constraint.
return {
# Core Objects
"accounts": {
@@ -85,9 +79,9 @@ def expected_metadata(self):
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.FOREIGN_KEYS: {
- 'accessible_bidding_strategy_id',
- 'bidding_strategy_id',
- 'campaign_budget_id',
+ # 'accessible_bidding_strategy_id',
+ # 'bidding_strategy_id',
+ # 'campaign_budget_id',
'customer_id'
},
},
@@ -95,8 +89,8 @@ def expected_metadata(self):
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.FOREIGN_KEYS: {
- 'accessible_bidding_strategy_id',
- 'bidding_strategy_id',
+ # 'accessible_bidding_strategy_id',
+ # 'bidding_strategy_id',
'campaign_id',
'customer_id',
},
@@ -113,7 +107,10 @@ def expected_metadata(self):
'campaign_budgets': {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {"customer_id"},
+ self.FOREIGN_KEYS: {
+ "customer_id",
+ "campaign_id",
+ },
},
'bidding_strategies': {
self.PRIMARY_KEYS:{"id"},
@@ -127,115 +124,123 @@ def expected_metadata(self):
},
# Report objects
"age_range_performance_report": { # "age_range_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"audience_performance_report": { # "campaign_audience_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"campaign_performance_report": { # "campaign_audience_view"
- self.PRIMARY_KEYS: {"TODO"},
- self.REPLICATION_METHOD: self.INCREMENTAL,
- self.REPLICATION_KEYS: {"date"},
- },
- "call_metrics_call_details_report": { # "call_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
+ # TODO Post Alpha
+ # "call_metrics_call_details_report": { # "call_view"
+ # self.PRIMARY_KEYS: {"_sdc_record_hash"},
+ # self.REPLICATION_METHOD: self.INCREMENTAL,
+ # self.REPLICATION_KEYS: {"date"},
+ # },
"click_performance_report": { # "click_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"display_keyword_performance_report": { # "display_keyword_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"display_topics_performance_report": { # "topic_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"gender_performance_report": { # "gender_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "geo_performance_report": { # "geographic_view"
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- "geo_performance_report": { # "geographic_view", "user_location_view"
- self.PRIMARY_KEYS: {"TODO"},
+ "user_location_performance_report": { # "user_location_view"
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"keywordless_query_report": { # "dynamic_search_ads_search_term_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"keywords_performance_report": { # "keyword_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- # TODO Do the land page reports have a different name in UI from the resource?
- # TODO should they follow the _report naming convention
"landing_page_report": {
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"expanded_landing_page_report": {
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"placeholder_feed_item_report": { # "feed_item", "feed_item_target"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"placeholder_report": { # "feed_placeholder_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"placement_performance_report": { # "managed_placement_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"search_query_performance_report": { # "search_term_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"shopping_performance_report": { # "shopping_performance_view"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "user_location_performance_report": { # "user_location_view"
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"video_performance_report": { # "video"
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- # MISSING V1 reports
"account_performance_report": { # accounts
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"adgroup_performance_report": { # ad_group
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
"ad_performance_report": { # ads
- self.PRIMARY_KEYS: {"TODO"},
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
@@ -297,7 +302,8 @@ def expected_replication_keys(self):
def expected_automatic_fields(self):
auto_fields = {}
for k, v in self.expected_metadata().items():
- auto_fields[k] = v.get(self.PRIMARY_KEYS, set()) | v.get(self.REPLICATION_KEYS, set())
+ auto_fields[k] = v.get(self.PRIMARY_KEYS, set()) | v.get(self.REPLICATION_KEYS, set()) | \
+ v.get(self.FOREIGN_KEYS, set())
return auto_fields
@@ -449,8 +455,15 @@ def select_all_streams_and_fields(conn_id, catalogs, select_all_fields: bool = T
connections.select_catalog_and_fields_via_metadata(
conn_id, catalog, schema, [], non_selected_properties)
+ @staticmethod
+ def deselect_streams(conn_id, catalogs):
+ """Select all streams and all fields within streams"""
+ for catalog in catalogs:
+ schema = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
- def _select_streams_and_fields(self, conn_id, catalogs, select_default_fields, select_pagination_fields):
+ connections.deselect_catalog_via_metadata(conn_id, catalog, schema)
+
+ def _select_streams_and_fields(self, conn_id, catalogs, select_default_fields):
"""Select all streams and all fields within streams"""
for catalog in catalogs:
@@ -466,10 +479,6 @@ def _select_streams_and_fields(self, conn_id, catalogs, select_default_fields, s
non_selected_properties = properties.difference(
self.expected_default_fields()[catalog['stream_name']]
)
- elif select_pagination_fields:
- non_selected_properties = properties.difference(
- self.expected_pagination_fields()[catalog['stream_name']]
- )
else:
non_selected_properties = properties
@@ -519,9 +528,201 @@ def timedelta_formatted(self, dtime, days=0):
### Tap Specific Methods
##########################################################################
+ def select_all_streams_and_default_fields(self, conn_id, catalogs):
+ """Select all streams and all fields within streams"""
+ for catalog in catalogs:
+ if not self.is_report(catalog['tap_stream_id']):
+ raise RuntimeError("Method intended for report streams only.")
+
+ schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
+ metadata = schema_and_metadata['metadata']
+ properties = {md['breadcrumb'][-1]
+ for md in metadata
+ if len(md['breadcrumb']) > 0 and md['breadcrumb'][0] == 'properties'}
+ expected_fields = self.expected_default_fields()[catalog['stream_name']]
+ self.assertTrue(expected_fields.issubset(properties),
+ msg=f"{catalog['stream_name']} missing {expected_fields.difference(properties)}")
+ non_selected_properties = properties.difference(expected_fields)
+ connections.select_catalog_and_fields_via_metadata(
+ conn_id, catalog, schema_and_metadata, [], non_selected_properties
+ )
+
def is_report(self, stream):
return stream.endswith('_report')
# TODO exclusion rules
- # TODO core objects vs reports
+ @staticmethod
+ def expected_default_fields():
+ """
+ Report streams will select fields based on the default values that
+ are provided when selecting the report type in Google's UI when possible.
+ These fields do not translate perfectly to our report syncs and so a subset
+ of those fields are used in almost all cases here.
+
+ returns a dictionary of reports to standard fields
+ """
+ return {
+ 'ad_performance_report': {
+ 'average_cpc', # 'Avg. CPC',
+ 'clicks', # 'Clicks',
+ 'conversions', # 'Conversions',
+ 'cost_per_conversion', # 'Cost / conv.',
+ 'ctr', # 'CTR',
+ 'customer_id', # 'Customer ID',
+ 'impressions', # 'Impr.',
+ 'view_through_conversions', # 'View-through conv.',
+ },
+ "adgroup_performance_report": {
+ 'average_cpc', # Avg. CPC,
+ 'clicks', # Clicks,
+ 'conversions', # Conversions,
+ 'cost_per_conversion', # Cost / conv.,
+ 'ctr', # CTR,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'view_through_conversions', # View-through conv.,
+ },
+ "audience_performance_report": {
+ 'average_cpc', # Avg. CPC,
+ 'average_cpm', # Avg. CPM
+ 'clicks', # Clicks,
+ 'ctr', # CTR,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'ad_group_targeting_setting', # Targeting Setting,
+ },
+ "campaign_performance_report": {
+ 'average_cpc', # Avg. CPC,
+ 'clicks', # Clicks,
+ 'conversions', # Conversions,
+ 'cost_per_conversion', # Cost / conv.,
+ 'ctr', # CTR,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'view_through_conversions', # View-through conv.,
+ },
+ "click_performance_report": {
+ 'ad_group_ad',
+ 'ad_group_id',
+ 'ad_group_name',
+ 'ad_group_status',
+ 'ad_network_type',
+ 'area_of_interest',
+ 'campaign_location_target',
+ 'click_type',
+ 'clicks',
+ 'customer_descriptive_name',
+ 'customer_id',
+ 'device',
+ 'gclid',
+ 'location_of_presence',
+ 'month_of_year',
+ 'page_number',
+ 'slot',
+ 'user_list',
+ },
+ "display_keyword_performance_report": { # TODO NO DATA AVAILABLE
+ 'average_cpc', # Avg. CPC,
+ 'average_cpm', # Avg. CPM,
+ 'average_cpv', # Avg. CPV,
+ 'clicks', # Clicks,
+ 'conversions', # Conversions,
+ 'cost_per_conversion', # Cost / conv.,
+ 'impressions', # Impr.,
+ 'interaction_rate', # Interaction rate,
+ 'interactions', # Interactions,
+ 'view_through_conversions', # View-through conv.,
+ },
+ "display_topics_performance_report": { # TODO NO DATA AVAILABLE
+ 'ad_group_name', # 'ad_group', # Ad group,
+ 'average_cpc', # Avg. CPC,
+ 'average_cpm', # Avg. CPM,
+ 'campaign_name', # 'campaign', # Campaign,
+ 'clicks', # Clicks,
+ 'ctr', # CTR,
+ 'customer_currency_code', # 'currency_code', # Currency code,
+ 'impressions', # Impr.,
+ },
+ "placement_performance_report": { # TODO NO DATA AVAILABLE
+ 'clicks',
+ 'impressions', # Impr.,
+ 'ad_group_criterion_placement', # 'placement_group', 'placement_type',
+ },
+ # "keywords_performance_report": set(),
+ # "shopping_performance_report": set(),
+ "video_performance_report": {
+ 'campaign_name',
+ 'clicks',
+ 'video_quartile_p25_rate',
+ },
+ # NOTE AFTER THIS POINT COULDN"T FIND IN UI
+ "account_performance_report": {
+ 'average_cpc',
+ 'click_type',
+ 'clicks',
+ 'date',
+ 'descriptive_name',
+ 'id',
+ 'impressions',
+ 'invalid_clicks',
+ 'manager',
+ 'test_account',
+ 'time_zone',
+ },
+ "geo_performance_report": {
+ 'clicks',
+ 'ctr', # CTR,
+ 'impressions', # Impr.,
+ 'average_cpc',
+ 'conversions',
+ 'view_through_conversions', # View-through conv.,
+ 'cost_per_conversion', # Cost / conv.,
+ 'geo_target_region',
+ },
+ "gender_performance_report": {
+ 'ad_group_criterion_gender',
+ 'average_cpc', # Avg. CPC,
+ 'clicks', # Clicks,
+ 'conversions', # Conversions,
+ 'cost_per_conversion', # Cost / conv.,
+ 'ctr', # CTR,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'view_through_conversions', # View-through conv.,
+ },
+ "search_query_performance_report": {
+ 'clicks',
+ 'ctr', # CTR,
+ 'impressions', # Impr.,
+ 'average_cpc',
+ 'conversions',
+ 'view_through_conversions', # View-through conv.,
+ 'cost_per_conversion', # Cost / conv.,
+ 'search_term',
+ 'search_term_match_type',
+ },
+ "age_range_performance_report": {
+ 'clicks',
+ 'ctr', # CTR,
+ 'impressions', # Impr.,
+ 'average_cpc',
+ 'conversions',
+ 'view_through_conversions', # View-through conv.,
+ 'cost_per_conversion', # Cost / conv.,
+ 'ad_group_criterion_age_range', # 'Age',
+ },
+ 'placeholder_feed_item_report': {
+ 'clicks',
+ 'impressions',
+ 'placeholder_type',
+ },
+ 'placeholder_report': {
+ 'clicks',
+ 'cost_micros',
+ 'interactions',
+ 'placeholder_type',
+ },
+ # 'landing_page_report': set(), # TODO
+ # 'expanded_landing_page_report': set(), # TODO
+ }
diff --git a/tests/test_google_ads_automatic_fields.py b/tests/test_google_ads_automatic_fields.py
new file mode 100644
index 0000000..4491697
--- /dev/null
+++ b/tests/test_google_ads_automatic_fields.py
@@ -0,0 +1,121 @@
+"""Test tap discovery mode and metadata."""
+import re
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class AutomaticFieldsGoogleAds(GoogleAdsBase):
+ """
+ Test tap's sync mode can extract records for all streams
+ with minimum field selection.
+ """
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_auto_fields"
+
+ def test_error_case(self):
+ """
+ Testing that basic sync with minimum field selection results in Critical Errors with clear message.
+ """
+ print("Automatic Fields Test for tap-google-ads report streams")
+
+ # --- Test report streams throw an error --- #
+
+ streams_to_test = {stream for stream in self.expected_streams()
+ if self.is_report(stream)}
+
+ conn_id = connections.ensure_connection(self)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ catalogs_to_test = [catalog
+ for catalog in found_catalogs
+ if catalog["stream_name"] == stream]
+
+ # select all fields for core streams and...
+ self.select_all_streams_and_fields(
+ conn_id,
+ catalogs_to_test,
+ select_all_fields=False
+ )
+ try:
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+
+ self.assertEqual(1, exit_status.get('tap_exit_status'))
+ self.assertEqual(0, exit_status.get('target_exit_status'))
+ self.assertEqual(0, exit_status.get('discovery_exit_status'))
+ self.assertIsNone(exit_status.get('check_exit_status'))
+
+ # Verify error message tells user they must select an attribute/metric for the invalid stream
+ self.assertIn(
+ "Please select at least one attribute and metric in order to replicate",
+ exit_status.get("tap_error_message")
+ )
+ self.assertIn(stream, exit_status.get("tap_error_message"))
+
+ finally:
+ # deselect stream once it's been tested
+ self.deselect_streams(conn_id, catalogs_to_test)
+
+ def test_happy_path(self):
+ """
+ Testing that basic sync with minimum field selection functions without Critical Errors
+ """
+ print("Automatic Fields Test for tap-google-ads core streams")
+
+ # --- Start testing core streams --- #
+
+ conn_id = connections.ensure_connection(self)
+
+ streams_to_test = {stream for stream in self.expected_streams()
+ if not self.is_report(stream)}
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ # Perform table and field selection...
+ catalogs_to_test = [catalog for catalog in found_catalogs
+ if catalog['stream_name'] in streams_to_test]
+ # select all fields for core streams and...
+ self.select_all_streams_and_fields(conn_id, catalogs_to_test, select_all_fields=False)
+
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ # Verify the tap and target do not throw a critical error
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # acquire records from target output
+ synced_records = runner.get_records_from_target_output()
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ # # Verify that only the automatic fields are sent to the target.
+ expected_auto_fields = self.expected_automatic_fields()
+ expected_primary_key = list(self.expected_primary_keys()[stream])[0] # assumes no compound-pks
+ self.assertEqual(len(self.expected_primary_keys()[stream]), 1, msg="Compound pk not supported")
+ for record in synced_records[stream]['messages']:
+
+ record_primary_key_values = record['data'][expected_primary_key]
+ record_keys = set(record['data'].keys())
+
+ with self.subTest(primary_key=record_primary_key_values):
+ self.assertSetEqual(expected_auto_fields[stream], record_keys)
+
+ # Verify that all replicated records have unique primary key values.
+ actual_pks = [row.get('data').get(expected_primary_key) for row in
+ synced_records.get(stream, {'messages':[]}).get('messages', []) if row.get('data')]
+
+ self.assertCountEqual(actual_pks, set(actual_pks))
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index 3d3b095..33244b9 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -1,13 +1,15 @@
-"""Test tap discovery mode and metadata."""
+"""Test tap bookmarks and converstion window."""
import re
+from datetime import datetime as dt
+from datetime import timedelta
from tap_tester import menagerie, connections, runner
from base import GoogleAdsBase
-class DiscoveryTest(GoogleAdsBase):
- """Test tap discovery mode and metadata conforms to standards."""
+class BookmarksTest(GoogleAdsBase):
+ """Test tap bookmarks."""
@staticmethod
def name():
@@ -15,54 +17,45 @@ def name():
def test_run(self):
"""
- Testing that basic sync functions without Critical Errors
+ Testing that the tap sets and uses bookmarks correctly
"""
print("Bookmarks Test for tap-google-ads")
conn_id = connections.ensure_connection(self)
streams_to_test = self.expected_streams() - {
- # TODO we are only testing core strems at the moment
- 'landing_page_report',
- 'expanded_landing_page_report',
+ 'audience_performance_report',
+ 'display_keyword_performance_report',
'display_topics_performance_report',
- 'call_metrics_call_details_report',
- 'gender_performance_report',
- 'search_query_performance_report',
- 'placeholder_feed_item_report',
+ 'expanded_landing_page_report',
+ 'keywordless_query_report',
'keywords_performance_report',
- 'video_performance_report',
- 'campaign_performance_report',
- 'geo_performance_report',
- 'placeholder_report',
+ 'landing_page_report',
'placement_performance_report',
- 'click_performance_report',
- 'display_keyword_performance_report',
+ 'search_query_performance_report',
'shopping_performance_report',
- 'ad_performance_report',
- 'age_range_performance_report',
- 'keywordless_query_report',
- 'account_performance_report',
- 'adgroup_performance_report',
- 'audience_performance_report',
+ 'user_location_performance_report',
+ 'video_performance_report',
}
# Run a discovery job
- check_job_name = runner.run_check_mode(self, conn_id)
- exit_status = menagerie.get_exit_status(conn_id, check_job_name)
- menagerie.verify_check_exit_status(self, exit_status, check_job_name)
+ found_catalogs_1 = self.run_and_verify_check_mode(conn_id)
- # Verify a catalog was produced for each stream under test
- found_catalogs = menagerie.get_catalogs(conn_id)
- self.assertGreater(len(found_catalogs), 0)
- found_catalog_names = {found_catalog['stream_name'] for found_catalog in found_catalogs}
- self.assertSetEqual(streams_to_test, found_catalog_names)
+ # partition catalogs for use in table/field seelction
+ test_catalogs_1 = [catalog for catalog in found_catalogs_1
+ if catalog.get('stream_name') in streams_to_test]
+ core_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if not self.is_report(catalog['stream_name'])]
+ report_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if self.is_report(catalog['stream_name'])]
- # Perform table and field selection
- self.select_all_streams_and_fields(conn_id, found_catalogs, select_all_fields=True)
+ # select all fields for core streams
+ self.select_all_streams_and_fields(conn_id, core_catalogs_1, select_all_fields=True)
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs_1)
- # Run a sync
+ # Run a sync
sync_job_name_1 = runner.run_sync_mode(self, conn_id)
# Verify the tap and target do not throw a critical error
@@ -72,6 +65,26 @@ def test_run(self):
# acquire records from target output
synced_records_1 = runner.get_records_from_target_output()
state_1 = menagerie.get_state(conn_id)
+ bookmarks_1 = state_1.get('bookmarks')
+ currently_syncing_1 = state_1.get('currently_syncing', 'KEY NOT SAVED IN STATE')
+
+ # TODO_TDL-17918 Determine if we can test all cases at the tap-tester level
+ # TEST CASE 1: state > today - converstion window, time format 2. Will age out and become TC2 Feb 24, 22
+ # TEST CASE 2: state < today - converstion window, time format 1
+ manipulated_state = {'currently_syncing': 'None',
+ 'bookmarks': {
+ 'adgroup_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ 'geo_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ 'gender_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ 'placeholder_feed_item_report': {'date': '2021-12-30T00:00:00.000000Z'},
+ 'age_range_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ 'account_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ 'click_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ 'campaign_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ 'placeholder_report': {'date': '2021-12-30T00:00:00.000000Z'},
+ 'ad_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
+ }}
+ menagerie.set_state(conn_id, manipulated_state)
# Run another sync
sync_job_name_2 = runner.run_sync_mode(self, conn_id)
@@ -83,36 +96,115 @@ def test_run(self):
# acquire records from target output
synced_records_2 = runner.get_records_from_target_output()
state_2 = menagerie.get_state(conn_id)
-
-
+ bookmarks_2 = state_2.get('bookmarks')
+ currently_syncing_2 = state_2.get('currently_syncing', 'KEY NOT SAVED IN STATE')
+
+ # Checking syncs were successful prior to stream-level assertions
+ with self.subTest():
+
+ # Verify sync is not interrupted by checking currently_syncing in state for sync 1
+ self.assertIsNone(currently_syncing_1)
+ # Verify bookmarks are saved
+ self.assertIsNotNone(bookmarks_1)
+
+ # Verify sync is not interrupted by checking currently_syncing in state for sync 2
+ self.assertIsNone(currently_syncing_2)
+ # Verify bookmarks are saved
+ self.assertIsNotNone(bookmarks_2)
+
+ # Verify ONLY report streams under test have bookmark entries in state
+ expected_incremental_streams = {stream for stream in streams_to_test if self.is_report(stream)}
+ unexpected_incremental_streams_1 = {stream for stream in bookmarks_1.keys() if stream not in expected_incremental_streams}
+ unexpected_incremental_streams_2 = {stream for stream in bookmarks_2.keys() if stream not in expected_incremental_streams}
+ self.assertSetEqual(set(), unexpected_incremental_streams_1)
+ self.assertSetEqual(set(), unexpected_incremental_streams_2)
+
+ # stream-level assertions
for stream in streams_to_test:
with self.subTest(stream=stream):
# set expectations
expected_replication_method = self.expected_replication_method()[stream]
+ conversion_window = timedelta(days=30) # defaulted value
# gather results
records_1 = [message['data'] for message in synced_records_1[stream]['messages']]
records_2 = [message['data'] for message in synced_records_2[stream]['messages']]
record_count_1 = len(records_1)
record_count_2 = len(records_2)
- bookmarks_1 = state_1.get(stream)
- bookmarks_2 = state_2.get(stream)
-
- # sanity check WIP
- print(f"Stream: {stream} \n"
- f"Record 1 Sync 1: {records_1[0]}")
- # end WIP
+ stream_bookmark_1 = bookmarks_1.get(stream)
+ stream_bookmark_2 = bookmarks_2.get(stream)
if expected_replication_method == self.INCREMENTAL:
# included to catch a contradiction in our base expectations
- if not stream.endswith('_report'):
+ if not self.is_report(stream):
raise AssertionError(
f"Only Reports streams should be expected to support {expected_replication_method} replication."
)
- # TODO need to finish implementing test cases for report streams
+ expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
+ manipulated_bookmark = manipulated_state['bookmarks'][stream]
+ today_minus_conversion_window = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - conversion_window
+ today = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+ manipulated_state_formatted = dt.strptime(manipulated_bookmark.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ if manipulated_state_formatted < today_minus_conversion_window:
+ reference_time = manipulated_state_formatted
+ else:
+ reference_time = today_minus_conversion_window
+
+ # Verify bookmarks saved match formatting standards for sync 1
+ self.assertIsNotNone(stream_bookmark_1)
+ bookmark_value_1 = stream_bookmark_1.get(expected_replication_key)
+ self.assertIsNotNone(bookmark_value_1)
+ self.assertIsInstance(bookmark_value_1, str)
+ try:
+ parsed_bookmark_value_1 = dt.strptime(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
+ except ValueError as err:
+ raise AssertionError(
+ f"Bookmarked value does not conform to expected format: {self.REPLICATION_KEY_FORMAT}"
+ ) from err
+
+ # Verify bookmarks saved match formatting standards for sync 2
+ self.assertIsNotNone(stream_bookmark_2)
+ bookmark_value_2 = stream_bookmark_2.get(expected_replication_key)
+ self.assertIsNotNone(bookmark_value_2)
+ self.assertIsInstance(bookmark_value_2, str)
+
+ try:
+ parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
+ except ValueError as err:
+
+ try:
+ parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, "%Y-%m-%dT%H:%M:%S.%fZ")
+ except ValueError as err:
+
+ raise AssertionError(
+ f"Bookmarked value does not conform to expected formats: " +
+ "\n Format 1: {}".format(self.REPLICATION_KEY_FORMAT) +
+ "\n Format 2: %Y-%m-%dT%H:%M:%S.%fZ"
+ ) from err
+
+ # Verify the bookmark is set based on sync execution time
+ self.assertGreaterEqual(parsed_bookmark_value_1, today) # TODO can we get more sepecifc with this?
+ self.assertGreaterEqual(parsed_bookmark_value_2, today)
+
+ # Verify 2nd sync only replicates records newer than reference_time
+ for record in records_2:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(rec_time, reference_time, \
+ msg="record time cannot be less than reference time: {}".format(reference_time)
+ )
+
+ # Verify the number of records in records_1 where sync >= reference_time
+ # matches the number of records in records_2
+ records_1_after_manipulated_bookmark = 0
+ for record in records_1:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ if rec_time >= reference_time:
+ records_1_after_manipulated_bookmark += 1
+ self.assertEqual(records_1_after_manipulated_bookmark, record_count_2, \
+ msg="Expected {} records in each sync".format(records_1_after_manipulated_bookmark))
elif expected_replication_method == self.FULL_TABLE:
@@ -120,8 +212,8 @@ def test_run(self):
self.assertEqual(record_count_1, record_count_2)
# Verify full table streams do not save bookmarked values at the conclusion of a succesful sync
- self.assertIsNone(bookmarks_1)
- self.assertIsNone(bookmarks_2)
+ self.assertIsNone(stream_bookmark_1)
+ self.assertIsNone(stream_bookmark_2)
# Verify full table streams replicate the same number of records on each sync
self.assertEqual(record_count_1, record_count_2)
@@ -129,9 +221,10 @@ def test_run(self):
# Verify full tables streams replicate the exact same set of records on each sync
for record in records_1:
self.assertIn(record, records_2)
-
+
# Verify at least 1 record was replicated for each stream
self.assertGreater(record_count_1, 0)
-
-
- print(f"{stream} {record_count_1} records replicated.")
+ self.assertGreater(record_count_2, 0)
+
+
+ print(f"{stream} sync 2 records replicated: {record_count_2}")
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index 3e8f531..e4ab434 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -10,14 +10,14 @@ class DiscoveryTest(GoogleAdsBase):
"""Test tap discovery mode and metadata conforms to standards."""
def expected_fields(self):
- """The expected streams and metadata about the streams"""
- # TODO verify accounts, ads, ad_groups, campaigns contain foreign keys for
- # 'campaign_budgets', 'bidding_strategies', 'accessible_bidding_strategies'
- # and only foreign keys BUT CHECK DOCS
+ """
+ The expected streams and metadata about the streams.
+ TODO's in this method will be picked up as part of TDL-17909
+ """
return {
# Core Objects
- "accounts": { # TODO check with Brian on changes
+ "accounts": { # TODO_TDL-17909 check with Brian on changes
# OLD FIELDS (with mapping)
"currency_code",
"id", # "customer_id",
@@ -41,7 +41,7 @@ def expected_fields(self):
'remarketing_setting.google_global_site_tag',
'tracking_url_template',
},
- "campaigns": { # TODO check out nested keys once these are satisfied
+ "campaigns": { # TODO_TDL-17909 check out nested keys once these are satisfied
# OLD FIELDS
"ad_serving_optimization_status",
"advertising_channel_type",
@@ -124,12 +124,12 @@ def expected_fields(self):
"vanity_pharma.vanity_pharma_text",
"video_brand_safety_suitability",
},
- "ad_groups": { # TODO check out nested keys once these are satisfied
+ "ad_groups": { # TODO_TDL-17909 check out nested keys once these are satisfied
# OLD FIELDS (with mappings)
"type", # ("ad_group_type")
"base_ad_group", # ("base_ad_group_id")
# "bidding_strategy_configuration", # DNE
- "campaign", #("campaign_name", "campaign_id", "base_campaign_id") # TODO redo this
+ "campaign", #("campaign_name", "campaign_id", "base_campaign_id") # TODO_TDL-17909 redo this
"id",
"labels",
"name",
@@ -161,7 +161,7 @@ def expected_fields(self):
"target_cpa_micros",
"effective_target_roas",
},
- "ads": { # TODO check out nested keys once these are satisfied
+ "ads": { # TODO_TDL-17909 check out nested keys once these are satisfied
# OLD FIELDS (with mappings)
"ad_group_id",
"base_ad_group_id",
@@ -199,10 +199,8 @@ def expected_fields(self):
},
"display_keyword_performance_report": { # "display_keyword_view"
},
- "display_topics_performance_report":{ # "topic_view"
- },
- "": { # "topic_view" todo consult https://developers.google.com/google-ads/api/docs/migration/url-reports for migrating this report
- },
+ "display_topics_performance_report": { # "topic_view"
+ },# TODO_TDL-17909 consult https://developers.google.com/google-ads/api/docs/migration/url-reports for migrating this report
"gender_performance_report": { # "gender_view"
},
"geo_performance_report": { # "geographic_view", "user_location_view"
@@ -233,7 +231,7 @@ def expected_fields(self):
},
"ad_performance_report": { # ads
},
- # Custom Reports TODO feature
+ # Custom Reports [OUTSIDE SCOPE OF ALPHA]
}
@staticmethod
@@ -259,44 +257,11 @@ def test_run(self):
"""
print("Discovery Test for tap-google-ads")
- conn_id = connections.ensure_connection(self)
+ streams_to_test = self.expected_streams()
- streams_to_test = self.expected_streams() - {
- # BUG_2 | missing
- 'landing_page_report',
- 'expanded_landing_page_report',
- 'display_topics_performance_report',
- 'call_metrics_call_details_report',
- 'gender_performance_report',
- 'search_query_performance_report',
- 'placeholder_feed_item_report',
- 'keywords_performance_report',
- 'video_performance_report',
- 'campaign_performance_report',
- 'geo_performance_report',
- 'placeholder_report',
- 'placement_performance_report',
- 'click_performance_report',
- 'display_keyword_performance_report',
- 'shopping_performance_report',
- 'ad_performance_report',
- 'age_range_performance_report',
- 'keywordless_query_report',
- 'account_performance_report',
- 'adgroup_performance_report',
- 'audience_performance_report',
- }
+ conn_id = connections.ensure_connection(self)
- # found_catalogs = self.run_and_verify_check_mode(conn_id) # TODO PUT BACK
- # TODO REMOVE FROM HERE
- check_job_name = runner.run_check_mode(self, conn_id)
- exit_status = menagerie.get_exit_status(conn_id, check_job_name)
- menagerie.verify_check_exit_status(self, exit_status, check_job_name)
- found_catalogs = menagerie.get_catalogs(conn_id)
- self.assertGreater(len(found_catalogs), 0)
- found_catalog_names = {found_catalog['stream_name'] for found_catalog in found_catalogs}
- self.assertSetEqual(streams_to_test, found_catalog_names)
- # TODO TO HERE
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
# Verify stream names follow naming convention
# streams should only have lowercase alphas and underscores
@@ -304,7 +269,7 @@ def test_run(self):
self.assertTrue(all([re.fullmatch(r"[a-z_]+", name) for name in found_catalog_names]),
msg="One or more streams don't follow standard naming")
- for stream in streams_to_test: # {'accounts', 'campaigns', 'ad_groups', 'ads'}: # # TODO PUT BACK
+ for stream in streams_to_test:
with self.subTest(stream=stream):
# Verify the catalog is found for a given stream
@@ -312,15 +277,17 @@ def test_run(self):
if catalog["stream_name"] == stream]))
self.assertIsNotNone(catalog)
- # collecting expected values
+ # collecting expected values from base.py
expected_primary_keys = self.expected_primary_keys()[stream]
expected_foreign_keys = self.expected_foreign_keys()[stream]
expected_replication_keys = self.expected_replication_keys()[stream]
- expected_automatic_fields = expected_primary_keys | expected_replication_keys
+ expected_automatic_fields = expected_primary_keys | expected_replication_keys | expected_foreign_keys
expected_replication_method = self.expected_replication_method()[stream]
- expected_fields = self.expected_fields()[stream]
+ # expected_fields = self.expected_fields()[stream] # TODO_TDL-17909
+ is_report = self.is_report(stream)
+ expected_behaviors = {'METRIC', 'SEGMENT', 'ATTRIBUTE'} if is_report else {'ATTRIBUTE', 'SEGMENT'}
- # collecting actual values
+ # collecting actual values from the catalog
schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
metadata = schema_and_metadata["metadata"]
stream_properties = [item for item in metadata if item.get("breadcrumb") == []]
@@ -328,10 +295,6 @@ def test_run(self):
stream_properties[0].get(
"metadata", {self.PRIMARY_KEYS: []}).get(self.PRIMARY_KEYS, [])
)
- actual_foreign_keys = set(
- stream_properties[0].get(
- "metadata", {self.FOREIGN_KEYS: []}).get(self.FOREIGN_KEYS, [])
- )
actual_replication_keys = set(
stream_properties[0].get(
"metadata", {self.REPLICATION_KEYS: []}).get(self.REPLICATION_KEYS, [])
@@ -340,12 +303,17 @@ def test_run(self):
"metadata", {self.REPLICATION_METHOD: None}).get(self.REPLICATION_METHOD)
actual_automatic_fields = set(
item.get("breadcrumb", ["properties", None])[1] for item in metadata
- if item.get("metadata").get("inclusion") == "automatic"
+ if item.get("metadata").get("inclusion") == "automatic"
)
actual_fields = []
for md_entry in metadata:
if md_entry['breadcrumb'] != []:
actual_fields.append(md_entry['breadcrumb'][1])
+ fields_to_behaviors = {item['breadcrumb'][-1]: item['metadata']['behavior']
+ for item in metadata
+ if item.get("breadcrumb", []) != []
+ and item.get("metadata").get("behavior")}
+ fields_with_behavior = set(fields_to_behaviors.keys())
##########################################################################
### metadata assertions
@@ -357,55 +325,69 @@ def test_run(self):
"\nstream_properties | {}".format(stream_properties))
# verify there are no duplicate metadata entries
- #self.assertEqual(len(actual_fields), len(set(actual_fields)), msg = f"duplicates in the fields retrieved")
+ self.assertEqual(len(actual_fields), len(set(actual_fields)), msg="duplicates in the fields retrieved")
+
- # TODO BUG (unclear on significance in saas tap ?)
# verify the tap_stream_id and stream_name are consistent (only applies to SaaS taps)
- # self.assertEqual(stream_properties[0]['stream_name'], stream_properties[0]['tap_stream_id'])
+ self.assertEqual(catalog['stream_name'], catalog['tap_stream_id'])
- # BUG_TDL_17533
- # [tap-google-ads] Primary keys have incorrect name for core objects
# verify primary key(s)
- # self.assertSetEqual(expected_primary_keys, actual_primary_keys) # BUG_TDL_17533
+ self.assertSetEqual(expected_primary_keys, actual_primary_keys)
- # BUG_1' | all core streams are missing this metadata TODO does this thing even get used ANYWHERE?
# verify replication method
- # self.assertEqual(expected_replication_method, actual_replication_method)
-
- # verify replication key(s)
- self.assertSetEqual(expected_replication_keys, actual_replication_keys)
-
- # TODO | implement when foreign keys are complete
- # verify foreign keys are present for each core stream
- # self.assertSetEqual(expected_foreign_keys, actual_foreign_keys)
+ self.assertEqual(expected_replication_method, actual_replication_method)
- # verify foreign keys are given inclusion of automatic
-
- # verify replication key is present for any stream with replication method = INCREMENTAL
+ # verify replication key is present for any stream with replication method is INCREMENTAL
if actual_replication_method == 'INCREMENTAL':
- # TODO | Implement at time sync is working
- # self.assertEqual(expected_replication_keys, actual_replication_keys)
- pass
+ self.assertNotEqual(actual_replication_keys, set())
else:
self.assertEqual(actual_replication_keys, set())
- # verify all expected fields are found # TODO set expectations
+ # verify replication key(s)
+ self.assertSetEqual(expected_replication_keys, actual_replication_keys)
+
+ # verify all expected fields are found # TODO_TDL-17909 set expectations
# self.assertSetEqual(expected_fields, set(actual_fields))
# verify the stream is given the inclusion of available
self.assertEqual(catalog['metadata']['inclusion'], 'available', msg=f"{stream} cannot be selected")
- # verify the primary, replication keys are given the inclusions of automatic
- #self.assertSetEqual(expected_automatic_fields, actual_automatic_fields)
+ # verify the primary, replication keys and foreign keys are given the inclusions of automatic
+ self.assertSetEqual(expected_automatic_fields, actual_automatic_fields)
# verify all other fields are given inclusion of available
self.assertTrue(
- all({item.get("metadata").get("inclusion") in {"available", "unsupported"}
+ all({item.get("metadata").get("inclusion") in {"available"}
for item in metadata
if item.get("breadcrumb", []) != []
and item.get("breadcrumb", ["properties", None])[1]
not in actual_automatic_fields}),
msg="Not all non key properties are set to available in metadata")
- # verify field exclusions for each strema match our expectations
- # TODO further tests may be needed, including attempted syncs with invalid field combos
+ # verify 'behavior' is present in metadata for all streams
+ if is_report:
+ actual_fields.remove('_sdc_record_hash')
+ self.assertEqual(fields_with_behavior, set(actual_fields))
+
+ # verify 'behavior' falls into expected set of behaviors (based on stream type)
+ for field, behavior in fields_to_behaviors.items():
+ with self.subTest(field=field):
+ self.assertIn(behavior, expected_behaviors)
+
+ # NB | The following assertion is left commented with the assumption that this will be a valid
+ # expectation by the time the tap moves to Beta. If this is not valid at that time it should
+ # be removed. Or if work done in TDL-17910 results in this being an unnecessary check
+
+ # if is_report:
+ # # verify each field in a report stream has a 'fieldExclusions' entry and that the fields listed
+ # # in that set are present in elsewhere in the stream's catalog
+ # fields_to_exclusions = {md['breadcrumb'][-1]: md['metadata']['fieldExclusions']
+ # for md in metadata
+ # if md['breadcrumb'] != [] and
+ # md['metadata'].get('fieldExclusions')}
+ # for field, exclusions in fields_to_exclusions.items():
+ # with self.subTest(field=field):
+ # self.assertTrue(
+ # set(exclusions).issubset(set(actual_fields)),
+ # msg=f"'fieldExclusions' contain fields not accounted for by the catalog: {set(exclusions) - set(actual_fields)}"
+ # )
diff --git a/tests/test_google_ads_start_date.py b/tests/test_google_ads_start_date.py
index 7025df7..10d6455 100644
--- a/tests/test_google_ads_start_date.py
+++ b/tests/test_google_ads_start_date.py
@@ -1,52 +1,24 @@
import os
+from datetime import datetime as dt
+
from tap_tester import connections, runner, menagerie
from base import GoogleAdsBase
class StartDateTest(GoogleAdsBase):
+ """A shared test to be ran with various configurations of start dates and streams to test"""
- start_date_1 = ""
- start_date_2 = ""
-
- @staticmethod
- def name():
- return "tt_google_ads_start_date"
-
- def test_run(self):
+ def run_test(self):
"""Instantiate start date according to the desired data set and run the test"""
- self.start_date_1 = self.get_properties().get('start_date') # '2020-12-01T00:00:00Z',
- self.start_date_2 = self.timedelta_formatted(self.start_date_1, days=15)
-
self.start_date = self.start_date_1
- streams_to_test = self.expected_streams() - {
- # TODO we are only testing core strems at the moment
- 'landing_page_report',
- 'expanded_landing_page_report',
- 'display_topics_performance_report',
- 'call_metrics_call_details_report',
- 'gender_performance_report',
- 'search_query_performance_report',
- 'placeholder_feed_item_report',
- 'keywords_performance_report',
- 'video_performance_report',
- 'campaign_performance_report',
- 'geo_performance_report',
- 'placeholder_report',
- 'placement_performance_report',
- 'click_performance_report',
- 'display_keyword_performance_report',
- 'shopping_performance_report',
- 'ad_performance_report',
- 'age_range_performance_report',
- 'keywordless_query_report',
- 'account_performance_report',
- 'adgroup_performance_report',
- 'audience_performance_report',
- }
+ streams_to_test = self.streams_to_test
+ print(f"Streams under test {streams_to_test}")
+ print(f"Start Date 1: {self.start_date_1}")
+ print(f"Start Date 2: {self.start_date_2}")
##########################################################################
### Sync with Connection 1
@@ -56,27 +28,20 @@ def test_run(self):
conn_id_1 = connections.ensure_connection(self)
# run check mode
- check_job_name_1 = runner.run_check_mode(self, conn_id_1) # TODO REMOVE START
- exit_status_1 = menagerie.get_exit_status(conn_id_1, check_job_name_1)
- menagerie.verify_check_exit_status(self, exit_status_1, check_job_name_1)
- found_catalogs_1 = menagerie.get_catalogs(conn_id_1) # TODO REMOVE END
- # found_catalogs_1 = self.run_and_verify_check_mode(conn_id_1) # TODO PUT BACK
+ found_catalogs_1 = self.run_and_verify_check_mode(conn_id_1)
# table and field selection
test_catalogs_1 = [catalog for catalog in found_catalogs_1
if catalog.get('stream_name') in streams_to_test]
- self.select_all_streams_and_fields(conn_id_1, test_catalogs_1, select_all_fields=True) # TODO REMOVE
- # self.perform_and_verify_table_and_field_selection(conn_id_1, test_catalogs_1, select_all_fields=True) # TODO PUT BACK
+ core_catalogs_1 = [catalog for catalog in test_catalogs_1 if not self.is_report(catalog['stream_name'])]
+ report_catalogs_1 = [catalog for catalog in test_catalogs_1 if self.is_report(catalog['stream_name'])]
+ # select all fields for core streams and...
+ self.select_all_streams_and_fields(conn_id_1, core_catalogs_1, select_all_fields=True)
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id_1, report_catalogs_1)
# run initial sync
- sync_job_name_1 = runner.run_sync_mode(self, conn_id_1) # TODO REMOVE START
- exit_status_1 = menagerie.get_exit_status(conn_id_1, sync_job_name_1)
- menagerie.verify_sync_exit_status(self, exit_status_1, sync_job_name_1)
- # BUG_TDL-17535 [tap-google-ads] Primary keys do not persist to target
- # record_count_by_stream_1 = runner.examine_target_output_file(
- # self, conn_id_1, streams_to_test, self.expected_primary_keys()) # BUG_TDL-17535
- # TODO REMOVE END
- # record_count_by_stream_1 = self.run_and_verify_sync(conn_id_1) # TODO PUT BACK
+ record_count_by_stream_1 = self.run_and_verify_sync(conn_id_1)
synced_records_1 = runner.get_records_from_target_output()
##########################################################################
@@ -94,96 +59,118 @@ def test_run(self):
conn_id_2 = connections.ensure_connection(self, original_properties=False)
# run check mode
- check_job_name_2 = runner.run_check_mode(self, conn_id_2) # TODO REMOVE START
- exit_status_2 = menagerie.get_exit_status(conn_id_2, check_job_name_2)
- menagerie.verify_check_exit_status(self, exit_status_2, check_job_name_2)
- found_catalogs_2 = menagerie.get_catalogs(conn_id_2) # TODO REMOVE END
- # found_catalogs_2 = self.run_and_verify_check_mode(conn_id_2) # TODO PUT BACK
-
+ found_catalogs_2 = self.run_and_verify_check_mode(conn_id_2)
# table and field selection
test_catalogs_2 = [catalog for catalog in found_catalogs_2
if catalog.get('stream_name') in streams_to_test]
- self.select_all_streams_and_fields(conn_id_2, test_catalogs_2, select_all_fields=True) # TODO REMOVE
- # self.perform_and_verify_table_and_field_selection(conn_id_2, test_catalogs_2, select_all_fields=True) # TODO PUT BACK
-
+ core_catalogs_2 = [catalog for catalog in test_catalogs_2 if not self.is_report(catalog['stream_name'])]
+ report_catalogs_2 = [catalog for catalog in test_catalogs_2 if self.is_report(catalog['stream_name'])]
+ # select all fields for core streams and...
+ self.select_all_streams_and_fields(conn_id_2, core_catalogs_2, select_all_fields=True)
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id_2, report_catalogs_2)
# run sync
- sync_job_name_2 = runner.run_sync_mode(self, conn_id_2) # TODO REMOVE START
- exit_status_2 = menagerie.get_exit_status(conn_id_2, sync_job_name_2)
- menagerie.verify_sync_exit_status(self, exit_status_2, sync_job_name_2)
- # BUG_TDL-17535 [tap-google-ads] Primary keys do not persist to target
- # record_count_by_stream_2 = runner.examine_target_output_file(
- # self, conn_id_2, streams_to_test, self.expected_primary_keys()) # BUG_TDL-17535
- # TODO REMOVE END
- # record_count_by_stream_2 = self.run_and_verify_sync(conn_id_2) # TODO PUT BACK
+ record_count_by_stream_2 = self.run_and_verify_sync(conn_id_2)
synced_records_2 = runner.get_records_from_target_output()
for stream in streams_to_test:
with self.subTest(stream=stream):
# expected values
- # expected_primary_keys = self.expected_primary_keys()[stream] # BUG_TDL_17533
- expected_primary_keys = {f'{stream}.id'} # BUG_TDL_17533
-
- # TODO update this with the lookback window DOES IT APPLY TO START DATE?
- # expected_conversion_window = -1 * int(self.get_properties()['conversion_window'])
- expected_start_date_1 = self.timedelta_formatted(self.start_date_1, days=0) # expected_conversion_window)
- expected_start_date_2 = self.timedelta_formatted(self.start_date_2, days=0) # expected_conversion_window)
+ expected_primary_keys = self.expected_primary_keys()[stream]
+ expected_start_date_1 = self.start_date_1
+ expected_start_date_2 = self.start_date_2
# collect information for assertions from syncs 1 & 2 base on expected values
- # record_count_sync_1 = record_count_by_stream_1.get(stream, 0) # BUG_TDL-17535
- # record_count_sync_2 = record_count_by_stream_2.get(stream, 0) # BUG_TDL-17535
- primary_keys_list_1 = [tuple(message.get('data').get(expected_pk) for expected_pk in expected_primary_keys)
- for message in synced_records_1.get(stream).get('messages')
- if message.get('action') == 'upsert']
- primary_keys_list_2 = [tuple(message.get('data').get(expected_pk) for expected_pk in expected_primary_keys)
- for message in synced_records_2.get(stream).get('messages')
- if message.get('action') == 'upsert']
+ record_count_sync_1 = record_count_by_stream_1.get(stream, 0)
+ record_count_sync_2 = record_count_by_stream_2.get(stream, 0)
+ primary_keys_list_1 = [tuple(message['data'][expected_pk] for expected_pk in expected_primary_keys)
+ for message in synced_records_1[stream]['messages']
+ if message['action'] == 'upsert']
+ primary_keys_list_2 = [tuple(message['data'][expected_pk] for expected_pk in expected_primary_keys)
+ for message in synced_records_2[stream]['messages']
+ if message['action'] == 'upsert']
primary_keys_sync_1 = set(primary_keys_list_1)
primary_keys_sync_2 = set(primary_keys_list_2)
if self.is_report(stream):
- # TODO IMPLEMENT WHEN REPORTS SYNC READY
- # # collect information specific to incremental streams from syncs 1 & 2
- # expected_replication_key = next(iter(self.expected_replication_keys().get(stream)))
- # replication_dates_1 =[row.get('data').get(expected_replication_key) for row in
- # synced_records_1.get(stream, {'messages': []}).get('messages', [])
- # if row.get('data')]
- # replication_dates_2 =[row.get('data').get(expected_replication_key) for row in
- # synced_records_2.get(stream, {'messages': []}).get('messages', [])
- # if row.get('data')]
-
- # # # Verify replication key is greater or equal to start_date for sync 1
- # for replication_date in replication_dates_1:
- # self.assertGreaterEqual(
- # self.parse_date(replication_date), self.parse_date(expected_start_date_1),
- # msg="Report pertains to a date prior to our start date.\n" +
- # "Sync start_date: {}\n".format(expected_start_date_1) +
- # "Record date: {} ".format(replication_date)
- # )
-
- # # Verify replication key is greater or equal to start_date for sync 2
- # for replication_date in replication_dates_2:
- # self.assertGreaterEqual(
- # self.parse_date(replication_date), self.parse_date(expected_start_date_2),
- # msg="Report pertains to a date prior to our start date.\n" +
- # "Sync start_date: {}\n".format(expected_start_date_2) +
- # "Record date: {} ".format(replication_date)
- # )
-
- # # Verify the number of records replicated in sync 1 is greater than the number
- # # of records replicated in sync 2
+ # collect information specific to incremental streams from syncs 1 & 2
+ expected_replication_key = next(iter(self.expected_replication_keys().get(stream)))
+
+ replication_dates_1 = [row.get('data').get(expected_replication_key) for row in
+ synced_records_1.get(stream, {'messages': []}).get('messages', [])
+ if row.get('data')]
+ replication_dates_2 = [row.get('data').get(expected_replication_key) for row in
+ synced_records_2.get(stream, {'messages': []}).get('messages', [])
+ if row.get('data')]
+
+ print(f"DATE BOUNDARIES SYNC 1: {stream} {sorted(replication_dates_1)[0]} {sorted(replication_dates_1)[-1]}")
+ # Verify replication key is greater or equal to start_date for sync 1
+ expected_start_date = dt.strptime(expected_start_date_1, self.START_DATE_FORMAT)
+ for replication_date in replication_dates_1:
+ replication_date = dt.strptime(replication_date, self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(replication_date, expected_start_date,
+ msg="Report pertains to a date prior to our start date.\n" +
+ "Sync start_date: {}\n".format(expected_start_date_1) +
+ "Record date: {} ".format(replication_date)
+ )
+
+ expected_start_date = dt.strptime(expected_start_date_2, self.START_DATE_FORMAT)
+ # Verify replication key is greater or equal to start_date for sync 2
+ for replication_date in replication_dates_2:
+ replication_date = dt.strptime(replication_date, self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(replication_date, expected_start_date,
+ msg="Report pertains to a date prior to our start date.\n" +
+ "Sync start_date: {}\n".format(expected_start_date_2) +
+ "Record date: {} ".format(replication_date)
+ )
+
+ # TODO Remove if this does not apply with the lookback window at the time that it is
+ # available as a configurable property.
+ # Verify the number of records replicated in sync 1 is greater than the number
+ # of records replicated in sync 2
# self.assertGreater(record_count_sync_1, record_count_sync_2)
- # # Verify the records replicated in sync 2 were also replicated in sync 1
- # self.assertTrue(primary_keys_sync_2.issubset(primary_keys_sync_1))
- pass
+ # Verify the records replicated in sync 2 were also replicated in sync 1
+ self.assertTrue(primary_keys_sync_2.issubset(primary_keys_sync_1))
+
else:
# Verify that the 2nd sync with a later start date (more recent) replicates
# the same number of records as the 1st sync.
- # self.assertEqual(record_count_sync_2, record_count_sync_1) # BUG_TDL-17535
+ self.assertEqual(record_count_sync_2, record_count_sync_1)
# Verify by primary key the same records are replicated in the 1st and 2nd syncs
self.assertSetEqual(primary_keys_sync_1, primary_keys_sync_2)
+
+class StartDateTest1(StartDateTest):
+
+ missing_coverage_streams = { # end result
+ 'display_keyword_performance_report', # no test data available
+ 'display_topics_performance_report', # no test data available
+ 'placement_performance_report', # no test data available
+ "keywords_performance_report", # no test data available
+ "keywordless_query_report", # no test data available
+ "video_performance_report", # no test data available
+ 'audience_performance_report',
+ "shopping_performance_report",
+ 'landing_page_report',
+ 'expanded_landing_page_report',
+ 'user_location_performance_report',
+ }
+
+ def setUp(self):
+ self.start_date_1 = self.get_properties().get('start_date') # '2021-12-01T00:00:00Z',
+ self.start_date_2 = self.timedelta_formatted(self.start_date_1, days=15)
+ self.streams_to_test = self.expected_streams() - {
+ 'search_query_performance_report', # Covered in other start date test
+ } - self.missing_coverage_streams # TODO
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_start_date"
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/test_google_ads_start_date_2.py b/tests/test_google_ads_start_date_2.py
new file mode 100644
index 0000000..63e4750
--- /dev/null
+++ b/tests/test_google_ads_start_date_2.py
@@ -0,0 +1,22 @@
+import os
+import unittest
+from datetime import datetime as dt
+
+from tap_tester import connections, runner, menagerie
+
+from base import GoogleAdsBase
+from test_google_ads_start_date import StartDateTest
+
+
+class StartDateTest2(StartDateTest):
+
+ def name(self):
+ return "tt_google_ads_start_date_2"
+
+ def setUp(self):
+ self.start_date_1 = '2022-01-20T00:00:00Z' # '2022-01-25T00:00:00Z',
+ self.start_date_2 = self.timedelta_formatted(self.start_date_1, days=2)
+ self.streams_to_test = {'search_query_performance_report'}
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index 004dbd9..dbd02d5 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -7,7 +7,295 @@
class DiscoveryTest(GoogleAdsBase):
- """Test tap discovery mode and metadata conforms to standards."""
+ """
+ Test tap's sync mode can extract records for all streams
+ with standard table and field selection.
+ """
+
+ @staticmethod
+ def expected_default_fields():
+ """
+ In this test core streams have all fields selected.
+
+ Report streams will select fields based on the default values that
+ are provided when selecting the report type in Google's UI.
+
+ returns a dictionary of reports to standard fields
+ """
+
+ # TODO_TDL-17909 [BUG?] commented out fields below are not discovered for the given stream by the tap
+ return {
+ 'ad_performance_report': {
+ # 'account_name', # 'Account name',
+ # 'ad_final_url', # 'Ad final URL',
+ # 'ad_group', # 'Ad group',
+ # 'ad_mobile_final_url', # 'Ad mobile final URL',
+ 'average_cpc', # 'Avg. CPC',
+ # 'business_name', # 'Business name',
+ # 'call_to_action_text', # 'Call to action text',
+ # 'campaign', # 'Campaign',
+ # 'campaign_subtype', # 'Campaign type',
+ # 'campaign_type', # 'Campaign subtype',
+ 'clicks', # 'Clicks',
+ 'conversions', # 'Conversions',
+ # 'conversion_rate', # 'Conv. rate',
+ # 'cost', # 'Cost',
+ 'cost_per_conversion', # 'Cost / conv.',
+ 'ctr', # 'CTR',
+ # 'currency_code', # 'Currency code',
+ 'customer_id', # 'Customer ID',
+ # 'description', # 'Description',
+ # 'description_1', # 'Description 1',
+ # 'description_2', # 'Description 2',
+ # 'description_3', # 'Description 3',
+ # 'description_4', # 'Description 4',
+ # 'final_url', # 'Final URL',
+ # 'headline_1', # 'Headline 1',
+ # 'headline_2', # 'Headline 2',
+ # 'headline_3', # 'Headline 3',
+ # 'headline_4', # 'Headline 4',
+ # 'headline_5', # 'Headline 5',
+ 'impressions', # 'Impr.',
+ # 'long_headline', # 'Long headline',
+ 'view_through_conversions', # 'View-through conv.',
+ },
+ "adgroup_performance_report": {
+ # 'account_name', # Account name,
+ # 'ad_group', # Ad group,
+ # 'ad_group_state', # Ad group state,
+ 'average_cpc', # Avg. CPC,
+ # 'campaign', # Campaign,
+ # 'campaign_subtype', # Campaign subtype,
+ # 'campaign_type', # Campaign type,
+ 'clicks', # Clicks,
+ # 'conversion_rate', # Conv. rate
+ 'conversions', # Conversions,
+ # 'cost', # Cost,
+ 'cost_per_conversion', # Cost / conv.,
+ 'ctr', # CTR,
+ # 'currency_code', # Currency code,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'view_through_conversions', # View-through conv.,
+ },
+ # TODO_TDL-17909 | [BUG?] missing audience fields
+ "audience_performance_report": {
+ # 'account_name', # Account name,
+ # 'ad_group_name', # 'ad_group', # Ad group,
+ # 'ad_group_default_max_cpc', # Ad group default max. CPC,
+ # 'audience_segment', # Audience segment,
+ # 'audience_segment_bid_adjustments', # Audience Segment Bid adj.,
+ # 'audience_segment_max_cpc', # Audience segment max CPC,
+ # 'audience_segment_state', # Audience segment state,
+ 'average_cpc', # Avg. CPC,
+ 'average_cpm', # Avg. CPM
+ # 'campaign', # Campaign,
+ 'clicks', # Clicks,
+ # 'cost', # Cost,
+ 'ctr', # CTR,
+ # 'currency_code', # Currency code,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'ad_group_targeting_setting', # Targeting Setting,
+ },
+ "campaign_performance_report": {
+ # 'account_name', # Account name,
+ 'average_cpc', # Avg. CPC,
+ # 'campaign', # Campaign,
+ # 'campaign_state', # Campaign state,
+ # 'campaign_type', # Campaign type,
+ 'clicks', # Clicks,
+ # 'conversion_rate', # Conv. rate
+ 'conversions', # Conversions,
+ # 'cost', # Cost,
+ 'cost_per_conversion', # Cost / conv.,
+ 'ctr', # CTR,
+ # 'currency_code', # Currency code,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'view_through_conversions', # View-through conv.,
+ },
+ "click_performance_report": {
+ 'ad_group_ad',
+ 'ad_group_id',
+ 'ad_group_name',
+ 'ad_group_status',
+ 'ad_network_type',
+ 'area_of_interest',
+ 'campaign_location_target',
+ 'click_type',
+ 'clicks',
+ 'customer_descriptive_name',
+ 'customer_id',
+ 'device',
+ 'gclid',
+ 'location_of_presence',
+ 'month_of_year',
+ 'page_number',
+ 'slot',
+ 'user_list',
+ },
+ "display_keyword_performance_report": { # TODO_TDL-17885 NO DATA AVAILABLE
+ # 'ad_group', # Ad group,
+ # 'ad_group_bid_strategy_type', # Ad group bid strategy type,
+ 'average_cpc', # Avg. CPC,
+ 'average_cpm', # Avg. CPM,
+ 'average_cpv', # Avg. CPV,
+ # 'campaign', # Campaign,
+ # 'campaign_bid_strategy_type', # Campaign bid strategy type,
+ # 'campaign_subtype', # Campaign subtype,
+ 'clicks', # Clicks,
+ # 'conversion_rate', # Conv. rate,
+ 'conversions', # Conversions,
+ # 'cost', # Cost,
+ 'cost_per_conversion', # Cost / conv.,
+ # 'currency_code', # Currency code,
+ # 'display_video_keyword', # Display/video keyword,
+ 'impressions', # Impr.,
+ 'interaction_rate', # Interaction rate,
+ 'interactions', # Interactions,
+ 'view_through_conversions', # View-through conv.,
+ },
+ "display_topics_performance_report": { # TODO_TDL-17885 NO DATA AVAILABLE
+ 'ad_group_name', # 'ad_group', # Ad group,
+ 'average_cpc', # Avg. CPC,
+ 'average_cpm', # Avg. CPM,
+ 'campaign_name', # 'campaign', # Campaign,
+ 'clicks', # Clicks,
+ # 'cost', # Cost,
+ 'ctr', # CTR,
+ 'customer_currency_code', # 'currency_code', # Currency code,
+ 'impressions', # Impr.,
+ # 'topic', # Topic,
+ # 'topic_state', # Topic state,
+ },
+ "placement_performance_report": { # TODO_TDL-17885 NO DATA AVAILABLE
+ # 'ad_group_name',
+ # 'ad_group_id',
+ # 'campaign_name',
+ # 'campaign_id',
+ 'clicks',
+ 'impressions', # Impr.,
+ # 'cost',
+ 'ad_group_criterion_placement', # 'placement_group', 'placement_type',
+ },
+ # "keywords_performance_report": set(),
+ # "shopping_performance_report": set(),
+ # BUG
+ "video_performance_report": {
+ # 'ad_group_name',
+ # 'all_conversions',
+ # 'all_conversions_from_interactions_rate',
+ # 'all_conversions_value',
+ # 'average_cpm',
+ # 'average_cpv',
+ 'campaign_name',
+ 'clicks',
+ # 'conversions',
+ # 'conversions_value',
+ # 'cost_per_all_conversions',
+ # 'cost_per_conversion',
+ # 'customer_descriptive_name',
+ # 'customer_id',
+ # 'impressions',
+ 'video_quartile_p25_rate',
+ # 'view_through_conversions',
+ },
+ # NOTE AFTER THIS POINT COULDN"T FIND IN UI
+ "account_performance_report": {
+ 'average_cpc',
+ 'click_type',
+ 'clicks',
+ 'date',
+ 'descriptive_name',
+ 'id',
+ 'impressions',
+ 'invalid_clicks',
+ 'manager',
+ 'test_account',
+ 'time_zone',
+ },
+ "geo_performance_report": {
+ 'clicks',
+ 'ctr', # CTR,
+ 'impressions', # Impr.,
+ 'average_cpc',
+ # 'cost',
+ 'conversions',
+ 'view_through_conversions', # View-through conv.,
+ 'cost_per_conversion', # Cost / conv.,
+ # 'conversion_rate', # Conv. rate
+ # 'geo_target_city',
+ # 'geo_target_metro',
+ # 'geo_target_most_specific_location',
+ 'geo_target_region',
+ # 'country_criterion_id', # TODO_TDL-17910 | [BUG?] PROHIBITED_RESOURCE_TYPE_IN_SELECT_CLAUSE
+ },
+ "gender_performance_report": {
+ # 'account_name', # Account name,
+ # 'ad_group', # Ad group,
+ # 'ad_group_state', # Ad group state,
+ 'ad_group_criterion_gender',
+ 'average_cpc', # Avg. CPC,
+ # 'campaign', # Campaign,
+ # 'campaign_subtype', # Campaign subtype,
+ # 'campaign_type', # Campaign type,
+ 'clicks', # Clicks,
+ # 'conversion_rate', # Conv. rate
+ 'conversions', # Conversions,
+ # 'cost', # Cost,
+ 'cost_per_conversion', # Cost / conv.,
+ 'ctr', # CTR,
+ # 'currency_code', # Currency code,
+ 'customer_id', # Customer ID,
+ 'impressions', # Impr.,
+ 'view_through_conversions', # View-through conv.,
+ },
+ "search_query_performance_report": {
+ 'clicks',
+ 'ctr', # CTR,
+ 'impressions', # Impr.,
+ 'average_cpc',
+ # 'cost',
+ 'conversions',
+ 'view_through_conversions', # View-through conv.,
+ 'cost_per_conversion', # Cost / conv.,
+ # 'conversion_rate', # Conv. rate
+ 'search_term',
+ 'search_term_match_type',
+ },
+ "age_range_performance_report": {
+ 'clicks',
+ 'ctr', # CTR,
+ 'impressions', # Impr.,
+ 'average_cpc',
+ # 'cost',
+ 'conversions',
+ 'view_through_conversions', # View-through conv.,
+ 'cost_per_conversion', # Cost / conv.,
+ # 'conversion_rate', # Conv. rate
+ 'ad_group_criterion_age_range', # 'Age',
+ },
+ 'placeholder_feed_item_report': {
+ 'clicks',
+ 'impressions',
+ 'placeholder_type',
+ },
+ 'placeholder_report': {
+ # 'ad_group_id',
+ # 'ad_group_name',
+ # 'average_cpc',
+ # 'click_type',
+ 'clicks',
+ 'cost_micros',
+ # 'customer_descriptive_name',
+ # 'customer_id',
+ 'interactions',
+ 'placeholder_type',
+ },
+ # 'landing_page_report': set(), # TODO_TDL-17885
+ # 'expanded_landing_page_report': set(), # TODO_TDL-17885
+ }
@staticmethod
def name():
@@ -17,51 +305,41 @@ def test_run(self):
"""
Testing that basic sync functions without Critical Errors
"""
- print("Discovery Test for tap-google-ads")
+ print("Canary Sync Test for tap-google-ads")
conn_id = connections.ensure_connection(self)
streams_to_test = self.expected_streams() - {
- # TODO we are only testing core strems at the moment
- 'landing_page_report',
- 'expanded_landing_page_report',
- 'display_topics_performance_report',
- 'call_metrics_call_details_report',
- 'gender_performance_report',
- 'search_query_performance_report',
- 'placeholder_feed_item_report',
- 'keywords_performance_report',
- 'video_performance_report',
- 'campaign_performance_report',
- 'geo_performance_report',
- 'placeholder_report',
- 'placement_performance_report',
- 'click_performance_report',
- 'display_keyword_performance_report',
- 'shopping_performance_report',
- 'ad_performance_report',
- 'age_range_performance_report',
- 'keywordless_query_report',
- 'account_performance_report',
- 'adgroup_performance_report',
- 'audience_performance_report',
+ # TODO_TDL-17885 the following are not yet implemented
+ 'display_keyword_performance_report', # no test data available
+ 'display_topics_performance_report', # no test data available
+ 'audience_performance_report', # Potential BUG see above
+ 'placement_performance_report', # no test data available
+ "keywords_performance_report", # no test data available
+ "keywordless_query_report", # no test data available
+ "shopping_performance_report", # cannot find this in GoogleUI
+ "video_performance_report", # no test data available
+ "user_location_performance_report", # no test data available
+ 'landing_page_report', # not attempted
+ 'expanded_landing_page_report', # not attempted
}
# Run a discovery job
- check_job_name = runner.run_check_mode(self, conn_id)
- exit_status = menagerie.get_exit_status(conn_id, check_job_name)
- menagerie.verify_check_exit_status(self, exit_status, check_job_name)
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
- # Verify a catalog was produced for each stream under test
- found_catalogs = menagerie.get_catalogs(conn_id)
- self.assertGreater(len(found_catalogs), 0)
- found_catalog_names = {found_catalog['stream_name'] for found_catalog in found_catalogs}
- self.assertSetEqual(streams_to_test, found_catalog_names)
+ # Perform table and field selection...
+ core_catalogs = [catalog for catalog in found_catalogs
+ if not self.is_report(catalog['stream_name'])
+ and catalog['stream_name'] in streams_to_test]
+ report_catalogs = [catalog for catalog in found_catalogs
+ if self.is_report(catalog['stream_name'])
+ and catalog['stream_name'] in streams_to_test]
+ # select all fields for core streams and...
+ self.select_all_streams_and_fields(conn_id, core_catalogs, select_all_fields=True)
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs)
- # Perform table and field selection
- self.select_all_streams_and_fields(conn_id, found_catalogs, select_all_fields=True)
-
- # Run a sync
+ # Run a sync
sync_job_name = runner.run_sync_mode(self, conn_id)
# Verify the tap and target do not throw a critical error
@@ -73,6 +351,9 @@ def test_run(self):
# Verify at least 1 record was replicated for each stream
for stream in streams_to_test:
- record_count = len(synced_records[stream]['messages'])
- self.assertGreater(record_count, 0)
+ with self.subTest(stream=stream):
+ record_count = len(synced_records.get(stream, {'messages': []})['messages'])
+ self.assertGreater(record_count, 0)
+ print(f"{record_count} {stream} record(s) replicated.")
+
diff --git a/tests/unittests/test_resource_schema.py b/tests/unittests/test_resource_schema.py
index 6d72e3d..7bf4f00 100644
--- a/tests/unittests/test_resource_schema.py
+++ b/tests/unittests/test_resource_schema.py
@@ -1,7 +1,7 @@
from collections import namedtuple
import unittest
-from tap_google_ads import get_segments
-from tap_google_ads import get_attributes
+from tap_google_ads.discover import get_segments
+from tap_google_ads.discover import get_attributes
RESOURCE_SCHEMA = {
diff --git a/tests/unittests/test_utils.py b/tests/unittests/test_utils.py
index 5f5fc0c..6916301 100644
--- a/tests/unittests/test_utils.py
+++ b/tests/unittests/test_utils.py
@@ -1,44 +1,216 @@
import unittest
-from tap_google_ads.reports import flatten
-from tap_google_ads.reports import make_field_names
+from tap_google_ads.streams import generate_hash
+from tap_google_ads.streams import get_query_date
+from tap_google_ads.streams import create_nested_resource_schema
+from singer import metadata
+from singer.utils import strptime_to_utc
+resource_schema = {
+ "accessible_bidding_strategy.id": {"json_schema": {"type": ["null", "integer"]}},
+ "accessible_bidding_strategy.strategy.id": {"json_schema": {"type": ["null", "integer"]}}
+}
+class TestCreateNestedResourceSchema(unittest.TestCase):
+ def test_one(self):
+ actual = create_nested_resource_schema(resource_schema, ["accessible_bidding_strategy.id"])
+ expected = {
+ "type": [
+ "null",
+ "object"
+ ],
+ "properties": {
+ "accessible_bidding_strategy" : {
+ "type": ["null", "object"],
+ "properties": {
+ "id": {
+ "type": [
+ "null",
+ "integer"
+ ]
+ }
+ }
+ }
+ }
+ }
+ self.assertDictEqual(expected, actual)
-class TestFlatten(unittest.TestCase):
- def test_flatten_one_level(self):
- nested_obj = {"a": {"b": "c"}, "d": "e"}
- actual = flatten(nested_obj)
- expected = {"a.b": "c", "d": "e"}
+ def test_two(self):
+ actual = create_nested_resource_schema(resource_schema, ["accessible_bidding_strategy.strategy.id"])
+ expected = {
+ "type": ["null", "object"],
+ "properties": {
+ "accessible_bidding_strategy": {
+ "type": ["null", "object"],
+ "properties": {
+ "strategy": {
+ "type": ["null", "object"],
+ "properties": {
+ "id": {"type": ["null", "integer"]}
+ }
+ }
+ }
+ }
+ }
+ }
self.assertDictEqual(expected, actual)
- def test_flatten_two_levels(self):
- nested_obj = {"a": {"b": {"c": "d", "e": "f"}, "g": "h"}}
- actual = flatten(nested_obj)
- expected = {"a.b.c": "d", "a.b.e": "f", "a.g": "h"}
+ def test_siblings(self):
+ actual = create_nested_resource_schema(
+ resource_schema,
+ ["accessible_bidding_strategy.id", "accessible_bidding_strategy.strategy.id"]
+ )
+ expected = {
+ "type": ["null", "object"],
+ "properties": {
+ "accessible_bidding_strategy": {
+ "type": ["null", "object"],
+ "properties": {
+ "strategy": {
+ "type": ["null", "object"],
+ "properties": {
+ "id": {"type": ["null", "integer"]}
+ }
+ },
+ "id": {"type": ["null", "integer"]}
+ }
+ }
+ }
+ }
self.assertDictEqual(expected, actual)
-class TestMakeFieldNames(unittest.TestCase):
- def test_single_word(self):
- actual = make_field_names("resource", ["type"])
- expected = ["resource.type"]
- self.assertListEqual(expected, actual)
+class TestRecordHashing(unittest.TestCase):
+
+ test_record = {
+ 'id': 1234567890,
+ 'currency_code': 'USD',
+ 'time_zone': 'America/New_York',
+ 'auto_tagging_enabled': False,
+ 'manager': False,
+ 'test_account': False,
+ 'impressions': 0,
+ 'interactions': 0,
+ 'invalid_clicks': 0,
+ 'date': '2022-01-19',
+ }
+
+ test_record_shuffled = {
+ 'currency_code': 'USD',
+ 'date': '2022-01-19',
+ 'auto_tagging_enabled': False,
+ 'time_zone': 'America/New_York',
+ 'test_account': False,
+ 'manager': False,
+ 'id': 1234567890,
+ 'interactions': 0,
+ 'invalid_clicks': 0,
+ 'impressions': 0,
+ }
+
+ test_metadata = metadata.to_list({
+ ('properties', 'id'): {'behavior': 'ATTRIBUTE'},
+ ('properties', 'currency_code'): {'behavior': 'ATTRIBUTE'},
+ ('properties', 'time_zone'): {'behavior': 'ATTRIBUTE'},
+ ('properties', 'auto_tagging_enabled'): {'behavior': 'ATTRIBUTE'},
+ ('properties', 'manager'): {'behavior': 'ATTRIBUTE'},
+ ('properties', 'test_account'): {'behavior': 'ATTRIBUTE'},
+ ('properties', 'impressions'): {'behavior': 'METRIC'},
+ ('properties', 'interactions'): {'behavior': 'METRIC'},
+ ('properties', 'invalid_clicks'): {'behavior': 'METRIC'},
+ ('properties', 'date'): {'behavior': 'SEGMENT'},
+ })
+
+ expected_hash = 'ade8240f134633fe125388e469e61ccf9e69033fd5e5f166b4b44766bc6376d3'
+
+ def test_record_hash_canary(self):
+ self.assertEqual(self.expected_hash, generate_hash(self.test_record, self.test_metadata))
+
+ def test_record_hash_is_same_regardless_of_order(self):
+ self.assertEqual(self.expected_hash, generate_hash(self.test_record, self.test_metadata))
+ self.assertEqual(self.expected_hash, generate_hash(self.test_record_shuffled, self.test_metadata))
+
+ def test_record_hash_is_same_with_fewer_metrics(self):
+ test_record_fewer_metrics = dict(self.test_record)
+ test_record_fewer_metrics.pop('interactions')
+ test_record_fewer_metrics.pop('invalid_clicks')
+ self.assertEqual(self.expected_hash, generate_hash(test_record_fewer_metrics, self.test_metadata))
+
+ def test_record_hash_is_different_with_non_metric_value(self):
+ test_diff_record = dict(self.test_record)
+ test_diff_record['date'] = '2022-02-03'
+ self.assertNotEqual(self.expected_hash, generate_hash(test_diff_record, self.test_metadata))
+
+
+class TestGetQueryDate(unittest.TestCase):
+ def test_one(self):
+ """Given:
+ - Start date before the conversion window
+ - No bookmark
+
+ return the start date"""
+ actual = get_query_date(
+ start_date="2022-01-01T00:00:00Z",
+ bookmark=None,
+ conversion_window_date="2022-01-23T00:00:00Z"
+ )
+ expected = strptime_to_utc("2022-01-01T00:00:00Z")
+ self.assertEqual(expected, actual)
+
+ def test_two(self):
+ """Given:
+ - Start date before the conversion window
+ - bookmark after the conversion window
+
+ return the conversion window"""
+ actual = get_query_date(
+ start_date="2022-01-01T00:00:00Z",
+ bookmark="2022-02-01T00:00:00Z",
+ conversion_window_date="2022-01-23T00:00:00Z"
+ )
+ expected = strptime_to_utc("2022-01-23T00:00:00Z")
+ self.assertEqual(expected, actual)
+
+ def test_three(self):
+ """Given:
+ - Start date after the conversion window
+ - no bookmark
+
+ return the start date"""
+ actual = get_query_date(
+ start_date="2022-02-01T00:00:00Z",
+ bookmark=None,
+ conversion_window_date="2022-01-23T00:00:00Z"
+ )
+ expected = strptime_to_utc("2022-02-01T00:00:00Z")
+ self.assertEqual(expected, actual)
- def test_dotted_field(self):
- actual = make_field_names("resource", ["tracking_setting.tracking_url"])
- expected = ["resource.tracking_setting.tracking_url"]
- self.assertListEqual(expected, actual)
+ def test_four(self):
+ """Given:
+ - Start date after the conversion window
+ - bookmark after the start date
- def test_foreign_key_field(self):
- actual = make_field_names("resource", ["customer_id", "accessible_bidding_strategy_id"])
- expected = ["customer.id", "accessible_bidding_strategy.id"]
- self.assertListEqual(expected, actual)
+ return the start date"""
+ actual = get_query_date(
+ start_date="2022-02-01T00:00:00Z",
+ bookmark="2022-02-08T00:00:00Z",
+ conversion_window_date="2022-01-23T00:00:00Z"
+ )
+ expected = strptime_to_utc("2022-02-01T00:00:00Z")
+ self.assertEqual(expected, actual)
- def test_trailing_id_field(self):
- actual = make_field_names("resource", ["owner_customer_id"])
- expected = ["resource.owner_customer_id"]
- self.assertListEqual(expected, actual)
+ def test_five(self):
+ """Given:
+ - Start date before the conversion window
+ - bookmark after the start date and before the conversion window
+ return the bookmark"""
+ actual = get_query_date(
+ start_date="2022-01-01T00:00:00Z",
+ bookmark="2022-01-14T00:00:00Z",
+ conversion_window_date="2022-01-23T00:00:00Z"
+ )
+ expected = strptime_to_utc("2022-01-14T00:00:00Z")
+ self.assertEqual(expected, actual)
if __name__ == '__main__':
unittest.main()
From 31efce61a97d720d33b079614daaab99d791d3dd Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dylan-stitch@users.noreply.github.com>
Date: Fri, 25 Feb 2022 10:29:09 -0500
Subject: [PATCH 21/69] Add build job to circle config. Update PR template
(#19)
---
.circleci/config.yml | 17 ++++++++++++-----
.github/pull_request_template.md | 10 +++++-----
2 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index de55bc9..90e502f 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -8,11 +8,10 @@ executors:
- image: 218546966473.dkr.ecr.us-east-1.amazonaws.com/circle-ci:stitch-tap-tester
jobs:
- # TODO remove if not needed
- # build:
- # executor: docker-executor
- # steps:
- # - run: echo 'CI done'
+ build:
+ executor: docker-executor
+ steps:
+ - run: echo 'CI done'
ensure_env:
executor: docker-executor
steps:
@@ -117,6 +116,14 @@ workflows:
- tier-1-tap-user
requires:
- ensure_env
+ - build:
+ context:
+ - circleci-user
+ - tier-1-tap-user
+ requires:
+ - run_pylint
+ - run_unit_tests
+ - run_integration_tests
build_daily:
<<: *commit_jobs
triggers:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 6e46b00..c71d3b3 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,11 +1,11 @@
# Description of change
-(write a short description or paste a link to JIRA)
+(write a short description here or paste a link to JIRA)
-# Manual QA steps
- -
+# QA steps
+ - [ ] automated tests passing
+ - [ ] manual qa steps passing (list below)
# Risks
- -
-
+
# Rollback steps
- revert this branch
From 2f17b8191ea03c240925652f8f2cce115365b3fd Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Wed, 2 Mar 2022 16:27:14 -0500
Subject: [PATCH 22/69] v0.1.0 (#23)
* Store bookmarks with only a midnight time value
* Add Changelog
* Cleanup bookmarks test
* increase no-output timeout
* address feedback in PR
* Add retry logic (#22)
* Add retry logic to the search queries
Reference code https://github.com/googleads/google-ads-python/blob/7ee4523d76a159f072541146339e7cb2721a3054/examples/misc/set_custom_client_timeouts.py#L106-L125
* Add error retry and log failed query on exception
* fix bookmarks test
* Remove selected fields log lines
* Silence google's error logging
* Prevent backoff from logging google's verbose errors
* Deduplicate log critical lines, remove unnecessary error logging
* Whitespace clean up
* Make pylint happy
* Fix field exclusion
Metrics and segments are the only fields that have exclusions, but those
exclusions can include attributes. And because we have the logic to prefer
using the root resource name when checking for `selectable_with`, we don't
need to worry about filtering out resources.
* Rename `adgroup` to `ad_group` and `audience_performance_report` to
`ad_group_audience_performance_report`. Add `campaign_audience_performance_report`
* Remove comments
* Bump to v0.1.0, update changelog
* Update tests to expect new streams
* Remove audience_performance_report, update comments
* Exclude `ad_group_audience_performance_report` from tests
* Renaming streams in discovery test
* Making tests happy w/ new stream names
* More test stream renaming. Rename confusing Class name to be accurate.
* Attempt to catch another Google Internal Error
* Make attribute exclusions mutual b/c bidding_strategy
* Link PR in Changelog
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: kspeer
---
.circleci/config.yml | 1 +
CHANGELOG.md | 14 +++
setup.py | 2 +-
tap_google_ads/__init__.py | 7 +-
tap_google_ads/discover.py | 11 --
tap_google_ads/report_definitions.py | 6 +-
tap_google_ads/streams.py | 89 +++++++++++++---
tests/base.py | 15 ++-
tests/test_google_ads_bookmarks.py | 149 +++++++++++++--------------
tests/test_google_ads_discovery.py | 6 +-
tests/test_google_ads_start_date.py | 3 +-
tests/test_google_ads_sync_canary.py | 12 +--
12 files changed, 194 insertions(+), 121 deletions(-)
create mode 100644 CHANGELOG.md
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 90e502f..ea2b107 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -76,6 +76,7 @@ jobs:
at: /usr/local/share/virtualenvs
- run:
name: 'Run Integration Tests'
+ no_output_timeout: 30m
command: |
aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
source dev_env.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0a2da4c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+## v0.1.0 [#23](https://github.com/singer-io/tap-google-ads/pull/23)
+ * Update bookmarks to only be written with a midnight time value
+ * Fix error logging to more concise
+ * Add retry logic to sync queries [#22](https://github.com/singer-io/tap-google-ads/pull/22)
+ * Fix field exclusion bug: Metrics can exclude attributes
+ * Rename:
+ * `adgroup_performance_report` to `ad_group_performance_report`
+ * `audience_performance_report` to `ad_group_audience_performance_report`
+ * Add `campaign_audience_performance_report`
+
+## v0.0.1
+ * Alpha Release [#13](https://github.com/singer-io/tap-google-ads/pull/13)
diff --git a/setup.py b/setup.py
index 5699664..d6b3498 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='0.0.1',
+ version='0.1.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
diff --git a/tap_google_ads/__init__.py b/tap_google_ads/__init__.py
index 307a96d..dc19fac 100644
--- a/tap_google_ads/__init__.py
+++ b/tap_google_ads/__init__.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
+import logging
import singer
from singer import utils
-
from tap_google_ads.discover import create_resource_schema
from tap_google_ads.discover import do_discover
from tap_google_ads.sync import do_sync
@@ -9,6 +9,7 @@
LOGGER = singer.get_logger()
+
REQUIRED_CONFIG_KEYS = [
"start_date",
"oauth_client_id",
@@ -38,10 +39,12 @@ def main_impl():
def main():
+ google_logger = logging.getLogger("google")
+ google_logger.setLevel(level=logging.CRITICAL)
+
try:
main_impl()
except Exception as e:
- LOGGER.exception(e)
for line in str(e).splitlines():
LOGGER.critical(line)
raise e
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
index 04df284..28ded2f 100644
--- a/tap_google_ads/discover.py
+++ b/tap_google_ads/discover.py
@@ -194,28 +194,17 @@ def create_resource_schema(config):
metrics_and_segments = set(metrics + segments)
for field_name, field in fields.items():
- if field["field_details"]["category"] == "ATTRIBUTE":
- continue
for compared_field in metrics_and_segments:
field_root_resource = get_root_resource_name(field_name)
compared_field_root_resource = get_root_resource_name(compared_field)
- # Fields can be any of the categories in CATEGORY_MAP, but only METRIC & SEGMENT have exclusions, so only check those
if (
field_name != compared_field
and not compared_field.startswith(f"{field_root_resource}.")
- ) and (
- fields[compared_field]["field_details"]["category"] == "METRIC"
- or fields[compared_field]["field_details"]["category"] == "SEGMENT"
):
-
field_to_check = field_root_resource or field_name
compared_field_to_check = compared_field_root_resource or compared_field
- # Metrics will not be incompatible with other metrics, so don't check those
- if field_name.startswith("metrics.") and compared_field.startswith("metrics."):
- continue
-
# If a resource is selectable with another resource they should be in
# each other's 'selectable_with' list, but Google is missing some of
# these so we have to check both ways
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index 071ffa7..669d359 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -78,7 +78,7 @@
"segments.week",
"segments.year",
]
-ADGROUP_PERFORMANCE_REPORT_FIELDS = [
+AD_GROUP_PERFORMANCE_REPORT_FIELDS = [
"ad_group.ad_rotation_mode",
"ad_group.base_ad_group",
"ad_group.cpc_bid_micros",
@@ -793,7 +793,7 @@
"ad_group_criterion.status",
"ad_group_criterion.tracking_url_template",
"ad_group_criterion.url_custom_parameters",
- "bidding_strategy.name", # bidding_strategy.name must be selected with the resources bidding_strategy and campaign.
+ "bidding_strategy.name",
"campaign.base_campaign",
"campaign.bidding_strategy",
"campaign.bidding_strategy_type",
@@ -882,7 +882,7 @@
"ad_group_criterion.topic.topic_constant",
"ad_group_criterion.tracking_url_template",
"ad_group_criterion.url_custom_parameters",
- "bidding_strategy.name", # bidding_strategy.name must be selected with the resources bidding_strategy and campaign.
+ "bidding_strategy.name",
"campaign.base_campaign",
"campaign.bidding_strategy",
"campaign.bidding_strategy_type",
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 09c3aad..81f1bf6 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -6,6 +6,8 @@
from singer import Transformer
from singer import utils
from google.protobuf.json_format import MessageToJson
+from google.ads.googleads.errors import GoogleAdsException
+import backoff
from . import report_definitions
LOGGER = singer.get_logger()
@@ -82,6 +84,49 @@ def generate_hash(record, metadata):
return hashlib.sha256(hash_bytes).hexdigest()
+retryable_errors = [
+ "QuotaError.RESOURCE_EXHAUSTED",
+ "QuotaError.RESOURCE_TEMPORARILY_EXHAUSTED",
+ "InternalError.INTERNAL_ERROR",
+ "InternalError.TRANSIENT_ERROR",
+ "InternalError.DEADLINE_EXCEEDED",
+]
+
+
+def should_give_up(ex):
+ if isinstance(ex, AttributeError):
+ if str(ex) == "'NoneType' object has no attribute 'Call'":
+ return False
+ return True
+
+ for googleads_error in ex.failure.errors:
+ quota_error = str(googleads_error.error_code.quota_error)
+ internal_error = str(googleads_error.error_code.internal_error)
+ for err in [quota_error, internal_error]:
+ if err in retryable_errors:
+ return False
+ return True
+
+
+def on_giveup_func(err):
+ """This function lets us know that backoff ran, but it does not print
+ Google's verbose message and stack trace"""
+ LOGGER.warning("Giving up make_request after %s tries", err.get("tries"))
+
+
+@backoff.on_exception(backoff.expo,
+ [GoogleAdsException,
+ AttributeError],
+ max_tries=5,
+ jitter=None,
+ giveup=should_give_up,
+ on_giveup=on_giveup_func,
+ logger=None)
+def make_request(gas, query, customer_id):
+ response = gas.search(query=query, customer_id=customer_id)
+ return response
+
+
class BaseStream: # pylint: disable=too-many-instance-attributes
def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys):
@@ -245,10 +290,14 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
stream_mdata = stream["metadata"]
selected_fields = get_selected_fields(stream_mdata)
state = singer.set_currently_syncing(state, stream_name)
- LOGGER.info(f"Selected fields for stream {stream_name}: {selected_fields}")
query = create_core_stream_query(resource_name, selected_fields)
- response = gas.search(query=query, customer_id=customer["customerId"])
+ try:
+ response = make_request(gas, query, customer["customerId"])
+ except GoogleAdsException as err:
+ LOGGER.warning("Failed query: %s", query)
+ raise err
+
with Transformer() as transformer:
# Pages are fetched automatically while iterating through the response
for message in response:
@@ -390,7 +439,7 @@ def sync(self, sdk_client, customer, stream, config, state):
conversion_window = timedelta(
days=int(config.get("conversion_window") or DEFAULT_CONVERSION_WINDOW)
)
- conversion_window_date = utils.now() - conversion_window
+ conversion_window_date = utils.now().replace(hour=0, minute=0, second=0, microsecond=0) - conversion_window
query_date = get_query_date(
start_date=config["start_date"],
@@ -400,12 +449,11 @@ def sync(self, sdk_client, customer, stream, config, state):
end_date = utils.now()
if stream_name in REPORTS_WITH_90_DAY_MAX:
- cutoff = end_date - timedelta(days=90)
+ cutoff = end_date.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=90)
query_date = max(query_date, cutoff)
if query_date == cutoff:
LOGGER.info(f"Stream: {stream_name} supports only 90 days of data. Setting query date to {utils.strftime(query_date, '%Y-%m-%d')}.")
- LOGGER.info(f"Selected fields for stream {stream_name}: {selected_fields}")
singer.write_state(state)
if selected_fields == {'segments.date'}:
@@ -414,7 +462,14 @@ def sync(self, sdk_client, customer, stream, config, state):
while query_date < end_date:
query = create_report_query(resource_name, selected_fields, query_date)
LOGGER.info(f"Requesting {stream_name} data for {utils.strftime(query_date, '%Y-%m-%d')}.")
- response = gas.search(query=query, customer_id=customer["customerId"])
+
+ try:
+ response = make_request(gas, query, customer["customerId"])
+ except GoogleAdsException as err:
+ LOGGER.warning("Failed query: %s", query)
+ LOGGER.critical(str(err.failure.errors[0].message))
+ raise RuntimeError from None
+
with Transformer() as transformer:
# Pages are fetched automatically while iterating through the response
@@ -491,12 +546,18 @@ def initialize_reports(resource_schema):
resource_schema,
["_sdc_record_hash"],
),
- "adgroup_performance_report": ReportStream(
- report_definitions.ADGROUP_PERFORMANCE_REPORT_FIELDS,
+ "ad_group_performance_report": ReportStream(
+ report_definitions.AD_GROUP_PERFORMANCE_REPORT_FIELDS,
["ad_group"],
resource_schema,
["_sdc_record_hash"],
),
+ "ad_group_audience_performance_report": ReportStream(
+ report_definitions.AD_GROUP_AUDIENCE_PERFORMANCE_REPORT_FIELDS,
+ ["ad_group_audience_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
"ad_performance_report": ReportStream(
report_definitions.AD_PERFORMANCE_REPORT_FIELDS,
["ad_group_ad"],
@@ -509,18 +570,18 @@ def initialize_reports(resource_schema):
resource_schema,
["_sdc_record_hash"],
),
- "audience_performance_report": ReportStream(
- report_definitions.AD_GROUP_AUDIENCE_PERFORMANCE_REPORT_FIELDS,
- ["ad_group_audience_view"],
- resource_schema,
- ["_sdc_record_hash"],
- ),
"campaign_performance_report": ReportStream(
report_definitions.CAMPAIGN_PERFORMANCE_REPORT_FIELDS,
["campaign"],
resource_schema,
["_sdc_record_hash"],
),
+ "campaign_audience_performance_report": ReportStream(
+ report_definitions.CAMPAIGN_AUDIENCE_PERFORMANCE_REPORT_FIELDS,
+ ["campaign_audience_view"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ ),
"click_performance_report": ReportStream(
report_definitions.CLICK_PERFORMANCE_REPORT_FIELDS,
["click_view"],
diff --git a/tests/base.py b/tests/base.py
index 1990f6d..79fff02 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -128,12 +128,12 @@ def expected_metadata(self):
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- "audience_performance_report": { # "campaign_audience_view"
+ "campaign_performance_report": { # "campaign"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- "campaign_performance_report": { # "campaign_audience_view"
+ "campaign_audience_performance_report": { # "campaign_audience_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
@@ -234,7 +234,12 @@ def expected_metadata(self):
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- "adgroup_performance_report": { # ad_group
+ "ad_group_performance_report": { # ad_group
+ self.PRIMARY_KEYS: {"_sdc_record_hash"},
+ self.REPLICATION_METHOD: self.INCREMENTAL,
+ self.REPLICATION_KEYS: {"date"},
+ },
+ "ad_group_audience_performance_report": { # ad_group_audience_view
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
@@ -573,7 +578,7 @@ def expected_default_fields():
'impressions', # 'Impr.',
'view_through_conversions', # 'View-through conv.',
},
- "adgroup_performance_report": {
+ "ad_group_performance_report": {
'average_cpc', # Avg. CPC,
'clicks', # Clicks,
'conversions', # Conversions,
@@ -583,7 +588,7 @@ def expected_default_fields():
'impressions', # Impr.,
'view_through_conversions', # View-through conv.,
},
- "audience_performance_report": {
+ "ad_group_audience_performance_report": {
'average_cpc', # Avg. CPC,
'average_cpm', # Avg. CPM
'clicks', # Clicks,
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index 33244b9..30be7c7 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -15,16 +15,37 @@ class BookmarksTest(GoogleAdsBase):
def name():
return "tt_google_ads_bookmarks"
+ def assertIsDateFormat(self, value, str_format):
+ """
+ Assertion Method that verifies a string value is a formatted datetime with
+ the specified format.
+ """
+ try:
+ _ = dt.strptime(value, str_format)
+ except ValueError as err:
+ raise AssertionError(
+ f"Value does not conform to expected format: {str_format}"
+ ) from err
+
+
def test_run(self):
"""
- Testing that the tap sets and uses bookmarks correctly
+ Testing that the tap sets and uses bookmarks correctly where
+ state < (today - converstion window), therefore the state should be used
+ on sync 2
+
+ Outstanding Work:
+ TODO_TDL-17918 Determine if we can test the following case at the tap-tester level
+ A sync results in a state such that state > (today - converstion window), therfore the
+ tap should pick up based on (today - converstion window) on sync 2.
+
"""
print("Bookmarks Test for tap-google-ads")
conn_id = connections.ensure_connection(self)
- streams_to_test = self.expected_streams() - {
- 'audience_performance_report',
+ streams_under_test = self.expected_streams() - {
+ 'ad_group_audience_performance_report',
'display_keyword_performance_report',
'display_topics_performance_report',
'expanded_landing_page_report',
@@ -36,6 +57,7 @@ def test_run(self):
'shopping_performance_report',
'user_location_performance_report',
'video_performance_report',
+ 'campaign_audience_performance_report',
}
# Run a discovery job
@@ -43,7 +65,7 @@ def test_run(self):
# partition catalogs for use in table/field seelction
test_catalogs_1 = [catalog for catalog in found_catalogs_1
- if catalog.get('stream_name') in streams_to_test]
+ if catalog.get('stream_name') in streams_under_test]
core_catalogs_1 = [catalog for catalog in test_catalogs_1
if not self.is_report(catalog['stream_name'])]
report_catalogs_1 = [catalog for catalog in test_catalogs_1
@@ -56,11 +78,7 @@ def test_run(self):
self.select_all_streams_and_default_fields(conn_id, report_catalogs_1)
# Run a sync
- sync_job_name_1 = runner.run_sync_mode(self, conn_id)
-
- # Verify the tap and target do not throw a critical error
- exit_status_1 = menagerie.get_exit_status(conn_id, sync_job_name_1)
- menagerie.verify_sync_exit_status(self, exit_status_1, sync_job_name_1)
+ _ = self.run_and_verify_sync(conn_id)
# acquire records from target output
synced_records_1 = runner.get_records_from_target_output()
@@ -68,30 +86,30 @@ def test_run(self):
bookmarks_1 = state_1.get('bookmarks')
currently_syncing_1 = state_1.get('currently_syncing', 'KEY NOT SAVED IN STATE')
- # TODO_TDL-17918 Determine if we can test all cases at the tap-tester level
- # TEST CASE 1: state > today - converstion window, time format 2. Will age out and become TC2 Feb 24, 22
- # TEST CASE 2: state < today - converstion window, time format 1
- manipulated_state = {'currently_syncing': 'None',
- 'bookmarks': {
- 'adgroup_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- 'geo_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- 'gender_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- 'placeholder_feed_item_report': {'date': '2021-12-30T00:00:00.000000Z'},
- 'age_range_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- 'account_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- 'click_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- 'campaign_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- 'placeholder_report': {'date': '2021-12-30T00:00:00.000000Z'},
- 'ad_performance_report': {'date': '2022-01-24T00:00:00.000000Z'},
- }}
+ # inject a simulated state value for each report stream under test
+ data_set_state_value_1 = '2022-01-24T00:00:00.000000Z'
+ data_set_state_value_2 = '2021-12-30T00:00:00.000000Z'
+ injected_state_by_stream = {
+ 'ad_group_performance_report':data_set_state_value_1,
+ 'geo_performance_report':data_set_state_value_1,
+ 'gender_performance_report':data_set_state_value_1,
+ 'placeholder_feed_item_report':data_set_state_value_2,
+ 'age_range_performance_report':data_set_state_value_1,
+ 'account_performance_report':data_set_state_value_1,
+ 'click_performance_report':data_set_state_value_1,
+ 'campaign_performance_report':data_set_state_value_1,
+ 'placeholder_report':data_set_state_value_2,
+ 'ad_performance_report':data_set_state_value_1,
+ }
+ manipulated_state = {
+ 'currently_syncing': 'None',
+ 'bookmarks': {stream: {'date': injected_state_by_stream[stream]}
+ for stream in streams_under_test if self.is_report(stream)}
+ }
menagerie.set_state(conn_id, manipulated_state)
# Run another sync
- sync_job_name_2 = runner.run_sync_mode(self, conn_id)
-
- # Verify the tap and target do not throw a critical error
- exit_status_2 = menagerie.get_exit_status(conn_id, sync_job_name_2)
- menagerie.verify_sync_exit_status(self, exit_status_2, sync_job_name_2)
+ _ = self.run_and_verify_sync(conn_id)
# acquire records from target output
synced_records_2 = runner.get_records_from_target_output()
@@ -112,21 +130,25 @@ def test_run(self):
# Verify bookmarks are saved
self.assertIsNotNone(bookmarks_2)
- # Verify ONLY report streams under test have bookmark entries in state
- expected_incremental_streams = {stream for stream in streams_to_test if self.is_report(stream)}
- unexpected_incremental_streams_1 = {stream for stream in bookmarks_1.keys() if stream not in expected_incremental_streams}
- unexpected_incremental_streams_2 = {stream for stream in bookmarks_2.keys() if stream not in expected_incremental_streams}
+ # Verify ONLY report streams under test have bookmark entries in state for sync 1
+ expected_incremental_streams = {stream for stream in streams_under_test if self.is_report(stream)}
+ unexpected_incremental_streams_1 = {stream for stream in bookmarks_1.keys()
+ if stream not in expected_incremental_streams}
self.assertSetEqual(set(), unexpected_incremental_streams_1)
+
+ # Verify ONLY report streams under test have bookmark entries in state for sync 2
+ unexpected_incremental_streams_2 = {stream for stream in bookmarks_2.keys()
+ if stream not in expected_incremental_streams}
self.assertSetEqual(set(), unexpected_incremental_streams_2)
# stream-level assertions
- for stream in streams_to_test:
+ for stream in streams_under_test:
with self.subTest(stream=stream):
# set expectations
expected_replication_method = self.expected_replication_method()[stream]
conversion_window = timedelta(days=30) # defaulted value
-
+ today_datetime = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
# gather results
records_1 = [message['data'] for message in synced_records_1[stream]['messages']]
records_2 = [message['data'] for message in synced_records_2[stream]['messages']]
@@ -137,71 +159,48 @@ def test_run(self):
if expected_replication_method == self.INCREMENTAL:
- # included to catch a contradiction in our base expectations
- if not self.is_report(stream):
- raise AssertionError(
- f"Only Reports streams should be expected to support {expected_replication_method} replication."
- )
-
+ # gather expectations
expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
manipulated_bookmark = manipulated_state['bookmarks'][stream]
- today_minus_conversion_window = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - conversion_window
- today = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
manipulated_state_formatted = dt.strptime(manipulated_bookmark.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
- if manipulated_state_formatted < today_minus_conversion_window:
- reference_time = manipulated_state_formatted
- else:
- reference_time = today_minus_conversion_window
# Verify bookmarks saved match formatting standards for sync 1
self.assertIsNotNone(stream_bookmark_1)
bookmark_value_1 = stream_bookmark_1.get(expected_replication_key)
self.assertIsNotNone(bookmark_value_1)
self.assertIsInstance(bookmark_value_1, str)
- try:
- parsed_bookmark_value_1 = dt.strptime(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
- except ValueError as err:
- raise AssertionError(
- f"Bookmarked value does not conform to expected format: {self.REPLICATION_KEY_FORMAT}"
- ) from err
+ self.assertIsDateFormat(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
# Verify bookmarks saved match formatting standards for sync 2
self.assertIsNotNone(stream_bookmark_2)
bookmark_value_2 = stream_bookmark_2.get(expected_replication_key)
self.assertIsNotNone(bookmark_value_2)
self.assertIsInstance(bookmark_value_2, str)
+ self.assertIsDateFormat(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
- try:
- parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
- except ValueError as err:
-
- try:
- parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, "%Y-%m-%dT%H:%M:%S.%fZ")
- except ValueError as err:
-
- raise AssertionError(
- f"Bookmarked value does not conform to expected formats: " +
- "\n Format 1: {}".format(self.REPLICATION_KEY_FORMAT) +
- "\n Format 2: %Y-%m-%dT%H:%M:%S.%fZ"
- ) from err
+ # Verify the bookmark is set based on sync end date (today) for sync 1
+ # (The tap replicaates from the start date through to today)
+ parsed_bookmark_value_1 = dt.strptime(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
+ self.assertEqual(parsed_bookmark_value_1, today_datetime)
- # Verify the bookmark is set based on sync execution time
- self.assertGreaterEqual(parsed_bookmark_value_1, today) # TODO can we get more sepecifc with this?
- self.assertGreaterEqual(parsed_bookmark_value_2, today)
+ # Verify the bookmark is set based on sync execution time for sync 2
+ # (The tap replicaates from the manipulated state through to todayf)
+ parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
+ self.assertEqual(parsed_bookmark_value_2, today_datetime)
- # Verify 2nd sync only replicates records newer than reference_time
+ # Verify 2nd sync only replicates records newer than manipulated_state_formatted
for record in records_2:
rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
- self.assertGreaterEqual(rec_time, reference_time, \
- msg="record time cannot be less than reference time: {}".format(reference_time)
+ self.assertGreaterEqual(rec_time, manipulated_state_formatted, \
+ msg="record time cannot be less than reference time: {}".format(manipulated_state_formatted)
)
- # Verify the number of records in records_1 where sync >= reference_time
+ # Verify the number of records in records_1 where sync >= manipulated_state_formatted
# matches the number of records in records_2
records_1_after_manipulated_bookmark = 0
for record in records_1:
rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
- if rec_time >= reference_time:
+ if rec_time >= manipulated_state_formatted:
records_1_after_manipulated_bookmark += 1
self.assertEqual(records_1_after_manipulated_bookmark, record_count_2, \
msg="Expected {} records in each sync".format(records_1_after_manipulated_bookmark))
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index e4ab434..d63d6be 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -189,9 +189,9 @@ def expected_fields(self):
# Report objects
"age_range_performance_report": { # "age_range_view"
},
- "audience_performance_report": { # "campaign_audience_view"
+ "ad_group_audience_performance_report": { # "ad_group_audience_view"
},
- "campaign_performance_report": { # "campaign_audience_view"
+ "campaign_performance_report": { # "campaign"
},
"call_metrics_call_details_report": { # "call_view"
},
@@ -227,7 +227,7 @@ def expected_fields(self):
},
"account_performance_report": { # accounts
},
- "adgroup_performance_report": { # ad_group
+ "ad_group_performance_report": { # ad_group
},
"ad_performance_report": { # ads
},
diff --git a/tests/test_google_ads_start_date.py b/tests/test_google_ads_start_date.py
index 10d6455..3486a05 100644
--- a/tests/test_google_ads_start_date.py
+++ b/tests/test_google_ads_start_date.py
@@ -154,11 +154,12 @@ class StartDateTest1(StartDateTest):
"keywords_performance_report", # no test data available
"keywordless_query_report", # no test data available
"video_performance_report", # no test data available
- 'audience_performance_report',
+ 'ad_group_audience_performance_report',
"shopping_performance_report",
'landing_page_report',
'expanded_landing_page_report',
'user_location_performance_report',
+ 'campaign_audience_performance_report',
}
def setUp(self):
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index dbd02d5..0b4dfdf 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -1,4 +1,3 @@
-"""Test tap discovery mode and metadata."""
import re
from tap_tester import menagerie, connections, runner
@@ -6,7 +5,7 @@
from base import GoogleAdsBase
-class DiscoveryTest(GoogleAdsBase):
+class SyncCanaryTest(GoogleAdsBase):
"""
Test tap's sync mode can extract records for all streams
with standard table and field selection.
@@ -59,7 +58,7 @@ def expected_default_fields():
# 'long_headline', # 'Long headline',
'view_through_conversions', # 'View-through conv.',
},
- "adgroup_performance_report": {
+ "ad_group_performance_report": {
# 'account_name', # Account name,
# 'ad_group', # Ad group,
# 'ad_group_state', # Ad group state,
@@ -79,7 +78,7 @@ def expected_default_fields():
'view_through_conversions', # View-through conv.,
},
# TODO_TDL-17909 | [BUG?] missing audience fields
- "audience_performance_report": {
+ "ad_group_audience_performance_report": {
# 'account_name', # Account name,
# 'ad_group_name', # 'ad_group', # Ad group,
# 'ad_group_default_max_cpc', # Ad group default max. CPC,
@@ -313,7 +312,7 @@ def test_run(self):
# TODO_TDL-17885 the following are not yet implemented
'display_keyword_performance_report', # no test data available
'display_topics_performance_report', # no test data available
- 'audience_performance_report', # Potential BUG see above
+ 'ad_group_audience_performance_report', # Potential BUG see above
'placement_performance_report', # no test data available
"keywords_performance_report", # no test data available
"keywordless_query_report", # no test data available
@@ -321,7 +320,8 @@ def test_run(self):
"video_performance_report", # no test data available
"user_location_performance_report", # no test data available
'landing_page_report', # not attempted
- 'expanded_landing_page_report', # not attempted
+ 'expanded_landing_page_report', # not attempted
+ 'campaign_audience_performance_report', # not attempted
}
# Run a discovery job
From 2a3ab025de1cb98485ec9fb87a7425f331750bc8 Mon Sep 17 00:00:00 2001
From: bhtowles
Date: Mon, 7 Mar 2022 10:02:44 -0500
Subject: [PATCH 23/69] Qa/exclusion completion (#26)
* Adding mutual field exclusion tests
* Updates to passing exclusion tests
* Clean up
* Updates from PR review
Co-authored-by: btowles
---
tests/base.py | 27 +-
tests/test_google_ads_field_exclusion.py | 188 ++++++++++++++
...test_google_ads_field_exclusion_invalid.py | 232 ++++++++++++++++++
3 files changed, 445 insertions(+), 2 deletions(-)
create mode 100644 tests/test_google_ads_field_exclusion.py
create mode 100644 tests/test_google_ads_field_exclusion_invalid.py
diff --git a/tests/base.py b/tests/base.py
index 79fff02..4a91698 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -46,10 +46,13 @@ def get_properties(self, original: bool = True):
return_value = {
'start_date': '2021-12-01T00:00:00Z',
'user_id': 'not used?', # TODO ?
- 'customer_ids': '5548074409,2728292456',
+ 'customer_ids': ",".join((os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'))),
# 'conversion_window_days': '30',
- 'login_customer_ids': [{"customerId": "5548074409", "loginCustomerId": "2728292456",}],
+ 'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ "loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
+
}
+
# TODO_TDL-17911 Add a test around conversion_window_days
if original:
return return_value
@@ -552,6 +555,26 @@ def select_all_streams_and_default_fields(self, conn_id, catalogs):
conn_id, catalog, schema_and_metadata, [], non_selected_properties
)
+ def select_stream_and_specified_fields(self, conn_id, catalog, fields_to_select: set()):
+ """
+ Select the specified stream and it's fields.
+ Intended only for report streams.
+ """
+ if not self.is_report(catalog['tap_stream_id']):
+ raise RuntimeError("Method intended for report streams only.")
+
+ schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
+ metadata = schema_and_metadata['metadata']
+ properties = {md['breadcrumb'][-1]
+ for md in metadata
+ if len(md['breadcrumb']) > 0 and md['breadcrumb'][0] == 'properties'}
+ self.assertTrue(fields_to_select.issubset(properties),
+ msg=f"{catalog['stream_name']} missing {fields_to_select.difference(properties)}")
+ non_selected_properties = properties.difference(fields_to_select)
+ connections.select_catalog_and_fields_via_metadata(
+ conn_id, catalog, schema_and_metadata, [], non_selected_properties
+ )
+
def is_report(self, stream):
return stream.endswith('_report')
diff --git a/tests/test_google_ads_field_exclusion.py b/tests/test_google_ads_field_exclusion.py
new file mode 100644
index 0000000..01ddd06
--- /dev/null
+++ b/tests/test_google_ads_field_exclusion.py
@@ -0,0 +1,188 @@
+"""Test tap field exclusions with random field selection."""
+import random
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class FieldExclusionGoogleAds(GoogleAdsBase):
+ """
+ Test tap's field exclusion logic for all streams
+
+ NOTE: Manual test case must be run at least once any time this feature changes or is updated.
+ Verify when given field selected, `fieldExclusions` fields in metadata are grayed out and cannot be selected (Manually)
+ """
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_field_exclusion"
+
+ def random_field_gather(self, input_fields_with_exclusions):
+ """
+ Method takes list of fields with exclusions and generates a random set fields without conflicts as a result
+ The set of fields with exclusions is generated in random order so that different combinations of fields can
+ be tested over time.
+ """
+
+ # Build random set of fields with exclusions. Select as many as possible
+ randomly_selected_list_of_fields_with_exclusions = []
+ remaining_available_fields_with_exclusions = input_fields_with_exclusions
+ while len(remaining_available_fields_with_exclusions) > 0:
+ # Randomly select one field that has exclusions
+ newly_added_field = remaining_available_fields_with_exclusions[
+ random.randrange(len(remaining_available_fields_with_exclusions))]
+ # Save list for debug incase test fails
+ self.random_order_of_exclusion_fields[self.stream].append(newly_added_field,)
+ randomly_selected_list_of_fields_with_exclusions.append(newly_added_field)
+ # Update remaining_available_fields_with_exclusinos based on random selection
+ newly_excluded_fields_to_remove = self.field_exclusions[newly_added_field]
+ # Remove newly selected field
+ remaining_available_fields_with_exclusions.remove(newly_added_field)
+ # Remove associated excluded fields
+ for field in newly_excluded_fields_to_remove:
+ if field in remaining_available_fields_with_exclusions:
+ remaining_available_fields_with_exclusions.remove(field)
+
+ exclusion_fields_to_select = randomly_selected_list_of_fields_with_exclusions
+
+ return exclusion_fields_to_select
+
+
+ def test_default_case(self):
+ """
+ Verify tap can perform sync for random combinations of fields that do not violate exclusion rules.
+ Established randomization for valid field selection using new method to select specific fields.
+ """
+ print("Field Exclusion Test with random field selection for tap-google-ads report streams")
+
+ # --- Test report streams --- #
+
+ streams_to_test = {stream for stream in self.expected_streams()
+ if self.is_report(stream)} - {'click_performance_report'} # No exclusions
+
+ streams_to_test = streams_to_test - {
+ # These streams missing from expected_default_fields() method TODO unblocked due to random? Test them now
+ # 'expanded_landing_page_report',
+ # 'shopping_performance_report',
+ # 'user_location_performance_report',
+ # 'keywordless_query_report',
+ # 'keywords_performance_report',
+ # 'landing_page_report',
+ # TODO These streams have no data to replicate and fail the last assertion
+ 'video_performance_report',
+ 'audience_performance_report',
+ 'placement_performance_report',
+ 'display_topics_performance_report',
+ 'display_keyword_performance_report',
+ }
+
+ #streams_to_test = {'gender_performance_report', 'placeholder_report',}
+
+ random_order_of_exclusion_fields = {}
+ conn_id = connections.ensure_connection(self)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ catalogs_to_test = [catalog
+ for catalog in found_catalogs
+ if catalog["stream_name"] == stream]
+
+ # Make second call to get field level metadata
+ schema = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
+ field_exclusions = {
+ rec['breadcrumb'][1]: rec['metadata']['fieldExclusions']
+ for rec in schema['metadata']
+ if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash"
+ }
+
+ self.field_exclusions = field_exclusions # expose filed_exclusions globally so other methods can use it
+
+ print(f"Perform assertions for stream: {stream}")
+
+ # Gather fields with no exclusions so they can all be added to selection set
+ fields_without_exclusions = []
+ for field, values in field_exclusions.items():
+ if values == []:
+ fields_without_exclusions.append(field)
+
+ # Gather fields with exclusions as input to randomly build maximum length selection set
+ fields_with_exclusions = []
+ for field, values in field_exclusions.items():
+ if values != []:
+ fields_with_exclusions.append(field)
+
+ if len(fields_with_exclusions) == 0:
+ raise AssertionError(f"Skipping assertions. No field exclusions for stream: {stream}")
+
+ self.stream = stream
+ random_order_of_exclusion_fields[stream] = []
+ self.random_order_of_exclusion_fields = random_order_of_exclusion_fields
+
+ random_exclusion_field_selection_list = self.random_field_gather(fields_with_exclusions)
+ field_selection_set = set(random_exclusion_field_selection_list + fields_without_exclusions)
+
+ with self.subTest(order_of_fields_selected=self.random_order_of_exclusion_fields[stream]):
+
+ # Select fields and re-pull annotated_schema.
+ self.select_stream_and_specified_fields(conn_id, catalogs_to_test[0], field_selection_set)
+
+ try:
+ # Collect updated metadata
+ schema_2 = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
+
+ # Verify metadata for all fields
+ for rec in schema_2['metadata']:
+ if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash":
+ # Verify metadata for selected fields
+ if rec['breadcrumb'][1] in field_selection_set:
+ self.assertEqual(rec['metadata']['selected'], True,
+ msg="Expected selection for field {} = 'True'".format(rec['breadcrumb'][1]))
+
+ else: # Verify metadata for non selected fields
+ self.assertEqual(rec['metadata']['selected'], False,
+ msg="Expected selection for field {} = 'False'".format(rec['breadcrumb'][1]))
+
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # These streams likely replicate records using the default field selection but may not produce any
+ # records when selecting this many fields with exclusions.
+ streams_unlikely_to_replicate_records = {
+ 'ad_performance_report',
+ 'account_performance_report',
+ 'shopping_performance_report',
+ 'search_query_performance_report',
+ 'placeholder_feed_item_report',
+ 'placeholder_report',
+ 'keywords_performance_report',
+ 'keywordless_query_report',
+ 'geo_performance_report',
+ 'gender_performance_report', # Very rare
+ 'ad_group_audience_performance_report',
+ 'age_range_performance_report',
+ 'campaign_audience_performance_report',
+ 'user_location_performance_report',
+ 'ad_group_performance_report',
+ }
+
+ if stream not in streams_unlikely_to_replicate_records:
+ sync_record_count = runner.examine_target_output_file(
+ self, conn_id, self.expected_streams(), self.expected_primary_keys())
+ self.assertGreater(
+ sum(sync_record_count.values()), 0,
+ msg="failed to replicate any data: {}".format(sync_record_count)
+ )
+ print("total replicated row count: {}".format(sum(sync_record_count.values())))
+
+ # TODO additional assertions?
+
+ finally:
+ # deselect stream once it's been tested
+ self.deselect_streams(conn_id, catalogs_to_test)
diff --git a/tests/test_google_ads_field_exclusion_invalid.py b/tests/test_google_ads_field_exclusion_invalid.py
new file mode 100644
index 0000000..fd04f35
--- /dev/null
+++ b/tests/test_google_ads_field_exclusion_invalid.py
@@ -0,0 +1,232 @@
+"""Test tap field exclusions for invalid selection sets."""
+import random
+import pprint
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class FieldExclusionInvalidGoogleAds(GoogleAdsBase):
+ """
+ Test tap's field exclusion logic with invalid selection for all streams
+
+ NOTE: Manual test case must be run at least once any time this feature changes or is updated.
+ Verify when given field selected, `fieldExclusions` fields in metadata are grayed out and cannot be selected (Manually)
+ """
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_field_exclusion_invalid"
+
+ def perform_exclusion_verification(self, field_exclusion_dict):
+ """
+ Verify for a pair of fields that if field_1 is in field_2's exclusion list then field_2 must be in field_1's exclusion list
+ """
+ error_dict = {}
+ for field, values in field_exclusion_dict.items():
+ if values != []:
+ for value in values:
+ if value in field_exclusion_dict.keys():
+ if field not in field_exclusion_dict[value]:
+ if field not in error_dict.keys():
+ error_dict[field] = [value]
+ else:
+ error_dict[field] += [value]
+
+ return error_dict
+
+ def random_field_gather(self, input_fields_with_exclusions):
+ """
+ Method takes list of fields with exclusions and generates a random set fields without conflicts as a result
+ The set of fields with exclusions is generated in random order so that different combinations of fields can
+ be tested over time. A single invalid field is then added to violate exclusion rules.
+ """
+
+ # Build random set of fields with exclusions. Select as many as possible
+ all_fields = input_fields_with_exclusions + self.fields_without_exclusions
+ randomly_selected_list_of_fields_with_exclusions = []
+ remaining_available_fields_with_exclusions = input_fields_with_exclusions
+ while len(remaining_available_fields_with_exclusions) > 0:
+ # Randomly select one field that has exclusions
+ newly_added_field = remaining_available_fields_with_exclusions[
+ random.randrange(len(remaining_available_fields_with_exclusions))]
+ # Save list for debug incase test fails
+ self.random_order_of_exclusion_fields[self.stream].append(newly_added_field,)
+ randomly_selected_list_of_fields_with_exclusions.append(newly_added_field)
+ # Update remaining_available_fields_with_exclusinos based on random selection
+ newly_excluded_fields_to_remove = self.field_exclusions[newly_added_field]
+ # Remove newly selected field
+ remaining_available_fields_with_exclusions.remove(newly_added_field)
+ # Remove associated excluded fields
+ for field in newly_excluded_fields_to_remove:
+ if field in remaining_available_fields_with_exclusions:
+ remaining_available_fields_with_exclusions.remove(field)
+
+ # Now add one more exclusion field to make the selection invalid
+ found_invalid_field = False
+ while found_invalid_field == False:
+ # Select a field from our list at random
+ invalid_field_partner = randomly_selected_list_of_fields_with_exclusions[
+ random.randrange(len(randomly_selected_list_of_fields_with_exclusions))]
+ # Find all fields excluded by selected field
+ invalid_field_pool = self.field_exclusions[invalid_field_partner]
+ # Remove any fields not in metadata properties for this stream
+ for field in reversed(invalid_field_pool):
+ if field not in all_fields:
+ invalid_field_pool.remove(field)
+
+ # Make sure there is still one left to select, if not try again
+ if len(invalid_field_pool) == 0:
+ continue
+
+ # Select field randomly and unset flag to terminate loop
+ invalid_field = invalid_field_pool[random.randrange(len(invalid_field_pool))]
+ found_invalid_field = True
+
+ # Add the invalid field to the lists
+ self.random_order_of_exclusion_fields[self.stream].append(invalid_field,)
+ randomly_selected_list_of_fields_with_exclusions.append(invalid_field)
+
+ exclusion_fields_to_select = randomly_selected_list_of_fields_with_exclusions
+
+ return exclusion_fields_to_select
+
+
+ def test_invalid_case(self):
+ """
+ Verify tap generates suitable error message when randomized combination of fields voilate exclusion rules.
+
+ Established randomization for valid field selection using new method to select specific fields.
+ Implemented random selection in valid selection test, then added a new field randomly to violate exclusion rules.
+
+ """
+ print("Field Exclusions - Invalid selection test for tap-google-ads report streams")
+
+ # --- Test report streams --- #
+
+ streams_to_test = {stream for stream in self.expected_streams()
+ if self.is_report(stream)} - {'click_performance_report'} # No exclusions. TODO remove dynamically
+
+ #streams_to_test = {'search_query_performance_report', 'placeholder_report',}
+
+ random_order_of_exclusion_fields = {}
+ tap_exit_status_by_stream = {}
+ exclusion_errors = {}
+ conn_id = connections.ensure_connection(self)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ # TODO Spike on running more than one sync per stream to increase the number of invalid field combos tested (Rushi)
+ catalogs_to_test = [catalog
+ for catalog in found_catalogs
+ if catalog["stream_name"] == stream]
+
+ # Make second call to get field metadata
+ schema = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
+ field_exclusions = {
+ rec['breadcrumb'][1]: rec['metadata']['fieldExclusions']
+ for rec in schema['metadata']
+ if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash"
+ }
+
+ self.field_exclusions = field_exclusions
+
+ # Gather fields with no exclusions so they can all be added to selection set
+ fields_without_exclusions = []
+ for field, values in field_exclusions.items():
+ if values == []:
+ fields_without_exclusions.append(field)
+ self.fields_without_exclusions = fields_without_exclusions
+
+ # Gather fields with exclusions as input to randomly build maximum length selection set
+ fields_with_exclusions = []
+ for field, values in field_exclusions.items():
+ if values != []:
+ fields_with_exclusions.append(field)
+ if len(fields_with_exclusions) == 0:
+ raise AssertionError(f"Skipping assertions. No field exclusions for stream: {stream}")
+
+ # Add new key to existing dicts
+ random_order_of_exclusion_fields[stream] = []
+ exclusion_errors[stream] = {}
+
+ # Expose variables globally
+ self.stream = stream
+ self.random_order_of_exclusion_fields = random_order_of_exclusion_fields
+
+ # Build random lists
+ random_exclusion_field_selection_list = self.random_field_gather(fields_with_exclusions)
+ field_selection_set = set(random_exclusion_field_selection_list + fields_without_exclusions)
+
+ # Collect any errors if they occur
+ exclusion_errors[stream] = self.perform_exclusion_verification(field_exclusions)
+
+ with self.subTest(order_of_fields_selected=self.random_order_of_exclusion_fields[stream]):
+
+ # Select fields and re-pull annotated_schema.
+ self.select_stream_and_specified_fields(conn_id, catalogs_to_test[0], field_selection_set)
+
+ try:
+ # Collect updated metadata
+ schema_2 = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
+
+ # Verify metadata for all fields
+ for rec in schema_2['metadata']:
+ if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash":
+ # Verify metadata for selected fields
+ if rec['breadcrumb'][1] in field_selection_set:
+ self.assertEqual(rec['metadata']['selected'], True,
+ msg="Expected selection for field {} = 'True'".format(rec['breadcrumb'][1]))
+
+ else: # Verify metadata for non selected fields
+ self.assertEqual(rec['metadata']['selected'], False,
+ msg="Expected selection for field {} = 'False'".format(rec['breadcrumb'][1]))
+
+ # # Run a sync
+ # sync_job_name = runner.run_sync_mode(self, conn_id)
+ # exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+
+ # print(f"Perform assertions for stream: {stream}")
+ # if exit_status.get('target_exit_status') == 1:
+ # #print(f"Stream {stream} has tap_exit_status = {exit_status.get('tap_exit_status')}\n" +
+ # # "Message: {exit_status.get('tap_error_message')")
+ # tap_exit_status_by_stream[stream] = exit_status.get('tap_exit_status')
+ # else:
+ # #print(f"\n*** {stream} tap_exit_status {exit_status.get('tap_exit_status')} ***\n")
+ # tap_exit_status_by_stream[stream] = exit_status.get('tap_exit_status')
+ # #self.assertEqual(1, exit_status.get('tap_exit_status')) # 11 failures on run 1
+ # self.assertEqual(0, exit_status.get('target_exit_status'))
+ # self.assertEqual(0, exit_status.get('discovery_exit_status'))
+ # self.assertIsNone(exit_status.get('check_exit_status'))
+
+ # Verify error message tells user they must select an attribute/metric for the invalid stream
+ # TODO build list of strings to test in future
+
+ # Initial assertion group generated if all fields selelcted
+ # self.assertIn(
+ # "PROHIBITED_FIELD_COMBINATION_IN_SELECT_CLAUSE",
+ # exit_status.get("tap_error_message")
+ # )
+ # self.assertIn(
+ # "The following pairs of fields may not be selected together",
+ # exit_status.get("tap_error_message")
+ # )
+
+ # New error message if random selection method is used
+ # PROHIBITED_SEGMENT_WITH_METRIC_IN_SELECT_OR_WHERE_CLAUSE
+
+ # TODO additional assertions?
+ # self.assertEqual(len(exclusion_erros[stream], 0)
+
+ finally:
+ # deselect stream once it's been tested
+ self.deselect_streams(conn_id, catalogs_to_test)
+
+ print("Streams tested: {}\ntap_exit_status_by_stream: {}".format(len(streams_to_test), tap_exit_status_by_stream))
+ print("Exclusion errors:")
+ pprint.pprint(exclusion_errors)
From d21388d0b50a6c1eecf3b0cefe90e4664c63e0f4 Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Mon, 7 Mar 2022 10:59:14 -0500
Subject: [PATCH 24/69] bump parallelism to 8 (#27)
Co-authored-by: kspeer
---
.circleci/config.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index ea2b107..7179ce9 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -69,7 +69,7 @@ jobs:
run_integration_tests:
executor: docker-executor
- parallelism: 6
+ parallelism: 8
steps:
- checkout
- attach_workspace:
From 092f8e09eeba4c938fa94a94972860341be2f1e8 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Fri, 11 Mar 2022 10:31:07 -0500
Subject: [PATCH 25/69] Revert removal of metric compatibility removal (#29)
* Revert removal of metric compatibility removal
* Whitespace cleanup
---
tap_google_ads/discover.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
index 28ded2f..aaa6646 100644
--- a/tap_google_ads/discover.py
+++ b/tap_google_ads/discover.py
@@ -205,6 +205,11 @@ def create_resource_schema(config):
field_to_check = field_root_resource or field_name
compared_field_to_check = compared_field_root_resource or compared_field
+ # The `selectable_with` for any given metric will not include
+ # any other metrics despite compatibility, so don't check those
+ if field_name.startswith("metrics.") and compared_field.startswith("metrics."):
+ continue
+
# If a resource is selectable with another resource they should be in
# each other's 'selectable_with' list, but Google is missing some of
# these so we have to check both ways
From 3c1357cfaa912fd4a2a38800b7ffc404fe7b7136 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Mon, 14 Mar 2022 13:47:08 -0400
Subject: [PATCH 26/69] Add currently syncing (#24)
* Sync all customers for a given stream
* Add logging to see when we retry requests
* Update currently_syncing with customerId too. Write state as soon as we
update it
* Add the customerId to the bookmark keys
* Add shuffle for customerId and tap_stream_id; add shuffle unit tests
* Bug fix for when currently_syncing is null
* Fix exception handling typeError
* Fix none cases for currently_syncing
* Fix currently_syncing to write a tuple we can read in later
* Add get_customer_ids so we can use it in the tests
* Fix manipulated_state to account for customer_ids
* Update assertion for currently_syncing
* Fix currently syncing assertion
* Move bookmark access into Full Table assertions section
Full Table doesn't need the "stream_name and customer id" key logic
* Remove duplicate assertion
* Revert 6db016e7ec29c2b00973b671c1efdf9451aca9c2
* Update bookmark to read stream->customer->replication_key
* Update tap to write bookmarks as stream->customer->replication_key
* Update manipulated state to nest stream->customer->replication_key
* Run bookmark assertions for every customer
* Fix dict comprehension typo
* Fix conflict with main
* Remove `get_state_key` again, use env var instead of hardcoded value
* Add missing dependency
* Move currently-syncing-null-out to the end of sync to prevent gaps
* Sort selected_streams and customers to guarantee consistency across runs
* Don't let the tap write (None, None)
* Sort selected_streams and customers effectively
* Update currently_syncing test assertions
* Add sort functions for streams and customers
* Update `shuffle` to handle a missing value
* Update unit tests to use sort_function, add a test for shuffling streams
* Add end date (#28)
* Add optional end date, add unit tests
Co-authored-by: Andy Lu
* Test functions can't be named run_test apparently
* Rename do_thing
* Extract `get_queries_from_sync` as a function
* Remove unused variable
* Refactor tests to be more explicit
* Mock singer.utils.now to return a specific date
Co-authored-by: Andy Lu
* add conversion_window test
* fixed conversion window unittests, bug removed
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: Andy Lu
Co-authored-by: kspeer
---
tap_google_ads/streams.py | 38 +++---
tap_google_ads/sync.py | 85 ++++++++++--
tests/base.py | 9 +-
tests/test_google_ads_bookmarks.py | 110 ++++++++--------
tests/unittests/test_conversion_window.py | 153 ++++++++++++++++++++++
tests/unittests/test_sync.py | 123 +++++++++++++++++
tests/unittests/test_utils.py | 142 ++++++++++++++++++++
7 files changed, 583 insertions(+), 77 deletions(-)
create mode 100644 tests/unittests/test_conversion_window.py
create mode 100644 tests/unittests/test_sync.py
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 81f1bf6..7cf2971 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -96,6 +96,7 @@ def generate_hash(record, metadata):
def should_give_up(ex):
if isinstance(ex, AttributeError):
if str(ex) == "'NoneType' object has no attribute 'Call'":
+ LOGGER.info('Retrying request due to AttributeError')
return False
return True
@@ -104,6 +105,7 @@ def should_give_up(ex):
internal_error = str(googleads_error.error_code.internal_error)
for err in [quota_error, internal_error]:
if err in retryable_errors:
+ LOGGER.info(f'Retrying request due to {err}')
return False
return True
@@ -115,8 +117,8 @@ def on_giveup_func(err):
@backoff.on_exception(backoff.expo,
- [GoogleAdsException,
- AttributeError],
+ (GoogleAdsException,
+ AttributeError),
max_tries=5,
jitter=None,
giveup=should_give_up,
@@ -289,7 +291,8 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
stream_name = stream["stream"]
stream_mdata = stream["metadata"]
selected_fields = get_selected_fields(stream_mdata)
- state = singer.set_currently_syncing(state, stream_name)
+ state = singer.set_currently_syncing(state, [stream_name, customer["customerId"]])
+ singer.write_state(state)
query = create_core_stream_query(resource_name, selected_fields)
try:
@@ -307,8 +310,6 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
singer.write_record(stream_name, record)
- state = singer.bookmarks.set_currently_syncing(state, None)
-
def get_query_date(start_date, bookmark, conversion_window_date):
"""Return a date within the conversion window and after start date
@@ -435,18 +436,29 @@ def sync(self, sdk_client, customer, stream, config, state):
stream_mdata = stream["metadata"]
selected_fields = get_selected_fields(stream_mdata)
replication_key = "date"
- state = singer.set_currently_syncing(state, stream_name)
+ state = singer.set_currently_syncing(state, [stream_name, customer["customerId"]])
+ singer.write_state(state)
+
conversion_window = timedelta(
days=int(config.get("conversion_window") or DEFAULT_CONVERSION_WINDOW)
)
conversion_window_date = utils.now().replace(hour=0, minute=0, second=0, microsecond=0) - conversion_window
+ bookmark_object = singer.get_bookmark(state, stream["tap_stream_id"], customer["customerId"], default={})
+
+ bookmark_value = bookmark_object.get(replication_key)
+
query_date = get_query_date(
start_date=config["start_date"],
- bookmark=singer.get_bookmark(state, stream_name, replication_key),
+ bookmark=bookmark_value,
conversion_window_date=singer.utils.strftime(conversion_window_date)
)
- end_date = utils.now()
+
+ end_date = config.get("end_date")
+ if end_date:
+ end_date = utils.strptime_to_utc(end_date)
+ else:
+ end_date = utils.now()
if stream_name in REPORTS_WITH_90_DAY_MAX:
cutoff = end_date.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=90)
@@ -454,12 +466,10 @@ def sync(self, sdk_client, customer, stream, config, state):
if query_date == cutoff:
LOGGER.info(f"Stream: {stream_name} supports only 90 days of data. Setting query date to {utils.strftime(query_date, '%Y-%m-%d')}.")
- singer.write_state(state)
-
if selected_fields == {'segments.date'}:
raise Exception(f"Selected fields is currently limited to {', '.join(selected_fields)}. Please select at least one attribute and metric in order to replicate {stream_name}.")
- while query_date < end_date:
+ while query_date <= end_date:
query = create_report_query(resource_name, selected_fields, query_date)
LOGGER.info(f"Requesting {stream_name} data for {utils.strftime(query_date, '%Y-%m-%d')}.")
@@ -481,15 +491,13 @@ def sync(self, sdk_client, customer, stream, config, state):
singer.write_record(stream_name, record)
- singer.write_bookmark(state, stream_name, replication_key, utils.strftime(query_date))
+ new_bookmark_value = {replication_key: utils.strftime(query_date)}
+ singer.write_bookmark(state, stream["tap_stream_id"], customer["customerId"], new_bookmark_value)
singer.write_state(state)
query_date += timedelta(days=1)
- state = singer.bookmarks.set_currently_syncing(state, None)
- singer.write_state(state)
-
def initialize_core_streams(resource_schema):
return {
diff --git a/tap_google_ads/sync.py b/tap_google_ads/sync.py
index 1628ad8..983264e 100644
--- a/tap_google_ads/sync.py
+++ b/tap_google_ads/sync.py
@@ -8,6 +8,53 @@
LOGGER = singer.get_logger()
+def get_currently_syncing(state):
+ currently_syncing = state.get("currently_syncing")
+
+ if not currently_syncing:
+ currently_syncing = (None, None)
+
+ resuming_stream, resuming_customer = currently_syncing
+ return resuming_stream, resuming_customer
+
+
+def sort_customers(customers):
+ return sorted(customers, key=lambda x: x["customerId"])
+
+def sort_selected_streams(sort_list):
+ return sorted(sort_list, key=lambda x: x["tap_stream_id"])
+
+
+def shuffle(shuffle_list, shuffle_key, current_value, sort_function):
+ """Return `shuffle_list` with `current_value` at the front of the list
+
+ In the scenario where `current_value` is not in `shuffle_list`:
+ - Assume that we have a consistent ordering to `shuffle_list`
+ - Insert the `current_value` into `shuffle_list`
+ - Sort the new list
+ - Do the normal logic to shuffle the list
+ - Return the new shuffled list without the `current_value` we inserted"""
+
+ fallback = False
+ if current_value not in [item[shuffle_key] for item in shuffle_list]:
+ fallback = True
+ shuffle_list.append({shuffle_key: current_value})
+ shuffle_list = sort_function(shuffle_list)
+
+ matching_index = 0
+ for i, key in enumerate(shuffle_list):
+ if key[shuffle_key] == current_value:
+ matching_index = i
+ break
+ top_half = shuffle_list[matching_index:]
+ bottom_half = shuffle_list[:matching_index]
+
+ if fallback:
+ return top_half[1:] + bottom_half
+
+ return top_half + bottom_half
+
+
def do_sync(config, catalog, resource_schema, state):
# QA ADDED WORKAROUND [START]
try:
@@ -15,25 +62,44 @@ def do_sync(config, catalog, resource_schema, state):
except TypeError: # falling back to raw value
customers = config["login_customer_ids"]
# QA ADDED WORKAROUND [END]
+ customers = sort_customers(customers)
selected_streams = [
stream
for stream in catalog["streams"]
if singer.metadata.to_map(stream["metadata"])[()].get("selected")
]
+ selected_streams = sort_selected_streams(selected_streams)
core_streams = initialize_core_streams(resource_schema)
report_streams = initialize_reports(resource_schema)
+ resuming_stream, resuming_customer = get_currently_syncing(state)
- for customer in customers:
- LOGGER.info(f"Syncing customer Id {customer['customerId']} ...")
- sdk_client = create_sdk_client(config, customer["loginCustomerId"])
- for catalog_entry in selected_streams:
- stream_name = catalog_entry["stream"]
- mdata_map = singer.metadata.to_map(catalog_entry["metadata"])
+ if resuming_stream:
+ selected_streams = shuffle(
+ selected_streams,
+ "tap_stream_id",
+ resuming_stream,
+ sort_function=sort_selected_streams
+ )
- primary_key = mdata_map[()].get("table-key-properties", [])
- singer.messages.write_schema(stream_name, catalog_entry["schema"], primary_key)
+ if resuming_customer:
+ customers = shuffle(
+ customers,
+ "customerId",
+ resuming_customer,
+ sort_function=sort_customers
+ )
+
+ for catalog_entry in selected_streams:
+ stream_name = catalog_entry["stream"]
+ mdata_map = singer.metadata.to_map(catalog_entry["metadata"])
+
+ primary_key = mdata_map[()].get("table-key-properties", [])
+ singer.messages.write_schema(stream_name, catalog_entry["schema"], primary_key)
+
+ for customer in customers:
+ sdk_client = create_sdk_client(config, customer["loginCustomerId"])
LOGGER.info(f"Syncing {stream_name} for customer Id {customer['customerId']}.")
@@ -43,3 +109,6 @@ def do_sync(config, catalog, resource_schema, state):
stream_obj = report_streams[stream_name]
stream_obj.sync(sdk_client, customer, catalog_entry, config, state)
+
+ state.pop("currently_syncing")
+ singer.write_state(state)
diff --git a/tests/base.py b/tests/base.py
index 4a91698..d411b43 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -41,16 +41,21 @@ def get_type():
"""the expected url route ending"""
return "platform.google-ads"
+ def get_customer_ids(self):
+ return [
+ os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),
+ ]
+
def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
'start_date': '2021-12-01T00:00:00Z',
'user_id': 'not used?', # TODO ?
- 'customer_ids': ",".join((os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'))),
+ 'customer_ids': ','.join(self.get_customer_ids()),
# 'conversion_window_days': '30',
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
"loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
-
}
# TODO_TDL-17911 Add a test around conversion_window_days
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index 30be7c7..84a814e 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -1,4 +1,5 @@
"""Test tap bookmarks and converstion window."""
+import os
import re
from datetime import datetime as dt
from datetime import timedelta
@@ -84,7 +85,7 @@ def test_run(self):
synced_records_1 = runner.get_records_from_target_output()
state_1 = menagerie.get_state(conn_id)
bookmarks_1 = state_1.get('bookmarks')
- currently_syncing_1 = state_1.get('currently_syncing', 'KEY NOT SAVED IN STATE')
+ currently_syncing_1 = state_1.get('currently_syncing')
# inject a simulated state value for each report stream under test
data_set_state_value_1 = '2022-01-24T00:00:00.000000Z'
@@ -101,10 +102,13 @@ def test_run(self):
'placeholder_report':data_set_state_value_2,
'ad_performance_report':data_set_state_value_1,
}
+
manipulated_state = {
- 'currently_syncing': 'None',
- 'bookmarks': {stream: {'date': injected_state_by_stream[stream]}
- for stream in streams_under_test if self.is_report(stream)}
+ 'bookmarks': {
+ stream: {os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'): {'date': injected_state_by_stream[stream]}}
+ for stream in streams_under_test
+ if self.is_report(stream)
+ }
}
menagerie.set_state(conn_id, manipulated_state)
@@ -115,7 +119,7 @@ def test_run(self):
synced_records_2 = runner.get_records_from_target_output()
state_2 = menagerie.get_state(conn_id)
bookmarks_2 = state_2.get('bookmarks')
- currently_syncing_2 = state_2.get('currently_syncing', 'KEY NOT SAVED IN STATE')
+ currently_syncing_2 = state_2.get('currently_syncing')
# Checking syncs were successful prior to stream-level assertions
with self.subTest():
@@ -161,52 +165,57 @@ def test_run(self):
# gather expectations
expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
- manipulated_bookmark = manipulated_state['bookmarks'][stream]
- manipulated_state_formatted = dt.strptime(manipulated_bookmark.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
-
- # Verify bookmarks saved match formatting standards for sync 1
- self.assertIsNotNone(stream_bookmark_1)
- bookmark_value_1 = stream_bookmark_1.get(expected_replication_key)
- self.assertIsNotNone(bookmark_value_1)
- self.assertIsInstance(bookmark_value_1, str)
- self.assertIsDateFormat(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
-
- # Verify bookmarks saved match formatting standards for sync 2
- self.assertIsNotNone(stream_bookmark_2)
- bookmark_value_2 = stream_bookmark_2.get(expected_replication_key)
- self.assertIsNotNone(bookmark_value_2)
- self.assertIsInstance(bookmark_value_2, str)
- self.assertIsDateFormat(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
-
- # Verify the bookmark is set based on sync end date (today) for sync 1
- # (The tap replicaates from the start date through to today)
- parsed_bookmark_value_1 = dt.strptime(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
- self.assertEqual(parsed_bookmark_value_1, today_datetime)
-
- # Verify the bookmark is set based on sync execution time for sync 2
- # (The tap replicaates from the manipulated state through to todayf)
- parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
- self.assertEqual(parsed_bookmark_value_2, today_datetime)
-
- # Verify 2nd sync only replicates records newer than manipulated_state_formatted
- for record in records_2:
- rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
- self.assertGreaterEqual(rec_time, manipulated_state_formatted, \
- msg="record time cannot be less than reference time: {}".format(manipulated_state_formatted)
- )
-
- # Verify the number of records in records_1 where sync >= manipulated_state_formatted
- # matches the number of records in records_2
- records_1_after_manipulated_bookmark = 0
- for record in records_1:
- rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
- if rec_time >= manipulated_state_formatted:
- records_1_after_manipulated_bookmark += 1
- self.assertEqual(records_1_after_manipulated_bookmark, record_count_2, \
- msg="Expected {} records in each sync".format(records_1_after_manipulated_bookmark))
+ testable_customer_ids = set(self.get_customer_ids()) - {'2728292456'}
+ for customer in testable_customer_ids:
+ with self.subTest(customer_id=customer):
+ manipulated_bookmark = manipulated_state['bookmarks'][stream]
+ manipulated_state_formatted = dt.strptime(
+ manipulated_bookmark.get(customer, {}).get(expected_replication_key),
+ self.REPLICATION_KEY_FORMAT
+ )
+
+ # Verify bookmarks saved match formatting standards for sync 1
+ self.assertIsNotNone(stream_bookmark_1)
+ bookmark_value_1 = stream_bookmark_1.get(customer, {}).get(expected_replication_key)
+ self.assertIsNotNone(bookmark_value_1)
+ self.assertIsInstance(bookmark_value_1, str)
+ self.assertIsDateFormat(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
+
+ # Verify bookmarks saved match formatting standards for sync 2
+ self.assertIsNotNone(stream_bookmark_2)
+ bookmark_value_2 = stream_bookmark_2.get(customer, {}).get(expected_replication_key)
+ self.assertIsNotNone(bookmark_value_2)
+ self.assertIsInstance(bookmark_value_2, str)
+ self.assertIsDateFormat(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
+
+ # Verify the bookmark is set based on sync end date (today) for sync 1
+ # (The tap replicaates from the start date through to today)
+ parsed_bookmark_value_1 = dt.strptime(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
+ self.assertEqual(parsed_bookmark_value_1, today_datetime)
+
+ # Verify the bookmark is set based on sync execution time for sync 2
+ # (The tap replicaates from the manipulated state through to todayf)
+ parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
+ self.assertEqual(parsed_bookmark_value_2, today_datetime)
+
+ # Verify 2nd sync only replicates records newer than manipulated_state_formatted
+ for record in records_2:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(rec_time, manipulated_state_formatted, \
+ msg="record time cannot be less than reference time: {}".format(manipulated_state_formatted)
+ )
+
+ # Verify the number of records in records_1 where sync >= manipulated_state_formatted
+ # matches the number of records in records_2
+ records_1_after_manipulated_bookmark = 0
+ for record in records_1:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ if rec_time >= manipulated_state_formatted:
+ records_1_after_manipulated_bookmark += 1
+ self.assertEqual(records_1_after_manipulated_bookmark, record_count_2, \
+ msg="Expected {} records in each sync".format(records_1_after_manipulated_bookmark))
elif expected_replication_method == self.FULL_TABLE:
-
# Verify full table streams replicate the same number of records on each sync
self.assertEqual(record_count_1, record_count_2)
@@ -214,9 +223,6 @@ def test_run(self):
self.assertIsNone(stream_bookmark_1)
self.assertIsNone(stream_bookmark_2)
- # Verify full table streams replicate the same number of records on each sync
- self.assertEqual(record_count_1, record_count_2)
-
# Verify full tables streams replicate the exact same set of records on each sync
for record in records_1:
self.assertIn(record, records_2)
diff --git a/tests/unittests/test_conversion_window.py b/tests/unittests/test_conversion_window.py
new file mode 100644
index 0000000..6b8d039
--- /dev/null
+++ b/tests/unittests/test_conversion_window.py
@@ -0,0 +1,153 @@
+import unittest
+from datetime import datetime
+from datetime import timedelta
+from unittest.mock import MagicMock
+from unittest.mock import Mock
+from unittest.mock import patch
+from tap_google_ads.streams import ReportStream
+from tap_google_ads.streams import make_request
+
+
+resource_schema = {
+ "accessible_bidding_strategy": {
+ "fields": {}
+ },
+
+}
+
+class TestBookmarkWithinConversionWindow(unittest.TestCase):
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_within_default_conversion_window(self, fake_make_request):
+ conversion_window = 30
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_within_60_day_conversion_window(self, fake_make_request):
+ conversion_window = 60
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_within_90_day_conversion_window(self, fake_make_request):
+ conversion_window = 90
+ self.execute(conversion_window, fake_make_request)
+
+ def execute(self, conversion_window, fake_make_request):
+ start_date = datetime(2021, 12, 1, 0, 0, 0)
+
+ # Create the stream so we can call sync
+ my_report_stream = ReportStream(
+ fields=[],
+ google_ads_resource_names=['accessible_bidding_strategy'],
+ resource_schema=resource_schema,
+ primary_keys=['foo']
+ )
+ config = {
+ "start_date": str(start_date),
+ "conversion_window": str(conversion_window),
+ }
+ end_date = datetime.now()
+ bookmark_value = str(end_date - timedelta(days=(conversion_window - 5)))
+
+ state = {
+ "currently_syncing": (None, None),
+ "bookmarks": {"hi": {"123": {'date': bookmark_value}},}
+ }
+ my_report_stream.sync(
+ Mock(),
+ {"customerId": "123",
+ "loginCustomerId": "456"},
+ {"tap_stream_id": "hi",
+ "stream": "hi",
+ "metadata": []},
+ config,
+ state,
+ )
+
+ all_queries_requested = []
+ for request_sent in fake_make_request.call_args_list:
+ # The function signature is gas, query, customer_id
+ _, query, _ = request_sent.args
+ all_queries_requested.append(query)
+
+
+ # Verify the first date queried is the conversion window date (not the bookmark)
+ expected_first_query_date = str(end_date - timedelta(days=conversion_window))[:10]
+ actual_first_query_date = str(all_queries_requested[0])[-11:-1]
+ self.assertEqual(expected_first_query_date, actual_first_query_date)
+
+ # Verify the number of days queried is based off the conversion window.
+ self.assertEqual(len(all_queries_requested), conversion_window + 1) # inclusive
+
+class TestBookmarkOnConversionWindow(unittest.TestCase):
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_1_day_conversion_window(self, fake_make_request):
+ conversion_window = 1
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_default_conversion_window(self, fake_make_request):
+ conversion_window = 30
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_60_day_conversion_window(self, fake_make_request):
+ conversion_window = 60
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_90_day_conversion_window(self, fake_make_request):
+ conversion_window = 90
+ self.execute(conversion_window, fake_make_request)
+
+ def execute(self, conversion_window, fake_make_request):
+ start_date = datetime(2021, 12, 1, 0, 0, 0)
+
+ # Create the stream so we can call sync
+ my_report_stream = ReportStream(
+ fields=[],
+ google_ads_resource_names=['accessible_bidding_strategy'],
+ resource_schema=resource_schema,
+ primary_keys=['foo']
+ )
+ config = {
+ "start_date": str(start_date),
+ "conversion_window": str(conversion_window),
+ }
+ end_date = datetime.now()
+ bookmark_value = str(end_date - timedelta(days=conversion_window))
+
+ state = {
+ "currently_syncing": (None, None),
+ "bookmarks": {"hi": {"123": {'date': bookmark_value}},}
+ }
+ my_report_stream.sync(
+ Mock(),
+ {"customerId": "123",
+ "loginCustomerId": "456"},
+ {"tap_stream_id": "hi",
+ "stream": "hi",
+ "metadata": []},
+ config,
+ state,
+ )
+
+ all_queries_requested = []
+ for request_sent in fake_make_request.call_args_list:
+ # The function signature is gas, query, customer_id
+ _, query, _ = request_sent.args
+ all_queries_requested.append(query)
+
+
+ # Verify the first date queried is the conversion window date (not the bookmark)
+ expected_first_query_date = str(bookmark_value)[:10]
+ actual_first_query_date = str(all_queries_requested[0])[-11:-1]
+ self.assertEqual(expected_first_query_date, actual_first_query_date)
+
+ # Verify the number of days queried is based off the conversion window.
+ self.assertEqual(len(all_queries_requested), conversion_window + 1) # inclusive
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unittests/test_sync.py b/tests/unittests/test_sync.py
new file mode 100644
index 0000000..7e44019
--- /dev/null
+++ b/tests/unittests/test_sync.py
@@ -0,0 +1,123 @@
+import unittest
+from datetime import datetime
+from datetime import timedelta
+from unittest.mock import MagicMock
+from unittest.mock import Mock
+from unittest.mock import patch
+from tap_google_ads.streams import ReportStream
+from tap_google_ads.streams import make_request
+import singer
+import pytz
+
+resource_schema = {
+ "accessible_bidding_strategy": {
+ "fields": {}
+ },
+
+}
+
+class TestEndDate(unittest.TestCase):
+
+ def get_queries_from_sync(self, fake_make_request):
+ all_queries_requested = []
+ for request_sent in fake_make_request.call_args_list:
+ # The function signature is gas, query, customer_id
+ _, query, _ = request_sent.args
+ all_queries_requested.append(query)
+ return all_queries_requested
+
+ def run_sync(self, start_date, end_date, fake_make_request):
+
+ # Create the stream so we can call sync
+ my_report_stream = ReportStream(
+ fields=[],
+ google_ads_resource_names=['accessible_bidding_strategy'],
+ resource_schema=resource_schema,
+ primary_keys=['foo']
+ )
+
+ # Create a config that maybe has an end date
+ config = {"start_date": str(start_date),}
+
+ # If end_date exists, write it to the config
+ if end_date:
+ config["end_date"] = str(end_date)
+
+ my_report_stream.sync(
+ Mock(),
+ {"customerId": "123",
+ "loginCustomerId": "456"},
+ {"tap_stream_id": "hi",
+ "stream": "hi",
+ "metadata": []},
+ config,
+ {}
+ )
+
+ @patch('singer.utils.now')
+ @patch('tap_google_ads.streams.make_request')
+ def test_no_end_date(self, fake_make_request, fake_datetime_now):
+ start_date = datetime(2022, 1, 1, 0, 0, 0)
+ end_date = datetime(2022, 3, 1, 0, 0, 0)
+
+ # Adding tzinfo helped the mock to work and avoids a
+ # TypeError(can't subtract offset-naive and offset-aware
+ # datetimes) here in the test
+ fake_datetime_now.return_value = end_date.replace(tzinfo=pytz.UTC)
+
+ # Don't pass in end_date to test the tap's fallback to today
+ self.run_sync(start_date, None, fake_make_request)
+ all_queries_requested = self.get_queries_from_sync(fake_make_request)
+
+ date_delta = end_date - start_date
+
+ # Add one to make it inclusive of the end date
+ days_between_start_and_end = date_delta.days + 1
+
+ # Compute the range of expected days, because end_date will always shift
+ expected_days = [
+ '2022-01-01', '2022-01-02', '2022-01-03', '2022-01-04',
+ '2022-01-05', '2022-01-06', '2022-01-07', '2022-01-08',
+ '2022-01-09', '2022-01-10', '2022-01-11', '2022-01-12',
+ '2022-01-13', '2022-01-14', '2022-01-15', '2022-01-16',
+ '2022-01-17', '2022-01-18', '2022-01-19', '2022-01-20',
+ '2022-01-21', '2022-01-22', '2022-01-23', '2022-01-24',
+ '2022-01-25', '2022-01-26', '2022-01-27', '2022-01-28',
+ '2022-01-29', '2022-01-30', '2022-01-31', '2022-02-01',
+ '2022-02-02', '2022-02-03', '2022-02-04', '2022-02-05',
+ '2022-02-06', '2022-02-07', '2022-02-08', '2022-02-09',
+ '2022-02-10', '2022-02-11', '2022-02-12', '2022-02-13',
+ '2022-02-14', '2022-02-15', '2022-02-16', '2022-02-17',
+ '2022-02-18', '2022-02-19', '2022-02-20', '2022-02-21',
+ '2022-02-22', '2022-02-23', '2022-02-24', '2022-02-25',
+ '2022-02-26', '2022-02-27', '2022-02-28', '2022-03-01',
+ ]
+
+ for day in expected_days:
+ self.assertTrue(
+ any(
+ day in query for query in all_queries_requested
+ )
+ )
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_end_date_one_day_after_start(self, fake_make_request):
+ start_date = datetime(2022, 3, 5, 0, 0, 0)
+ end_date = datetime(2022, 3, 6, 0, 0, 0)
+ self.run_sync(start_date, end_date, fake_make_request)
+ all_queries_requested = self.get_queries_from_sync(fake_make_request)
+
+ expected_days = [
+ "2022-03-05",
+ "2022-03-06",
+ ]
+
+ for day in expected_days:
+ self.assertTrue(
+ any(
+ day in query for query in all_queries_requested
+ )
+ )
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unittests/test_utils.py b/tests/unittests/test_utils.py
index 6916301..75e2842 100644
--- a/tests/unittests/test_utils.py
+++ b/tests/unittests/test_utils.py
@@ -2,6 +2,9 @@
from tap_google_ads.streams import generate_hash
from tap_google_ads.streams import get_query_date
from tap_google_ads.streams import create_nested_resource_schema
+from tap_google_ads.sync import shuffle
+from tap_google_ads.sync import sort_selected_streams
+from tap_google_ads.sync import sort_customers
from singer import metadata
from singer.utils import strptime_to_utc
@@ -212,5 +215,144 @@ def test_five(self):
expected = strptime_to_utc("2022-01-14T00:00:00Z")
self.assertEqual(expected, actual)
+
+class TestShuffleStreams(unittest.TestCase):
+ selected_streams = [
+ {"tap_stream_id": "stream1"},
+ {"tap_stream_id": "stream2"},
+ {"tap_stream_id": "stream3"},
+ {"tap_stream_id": "stream4"},
+ {"tap_stream_id": "stream5"},
+ ]
+
+ def test_shuffle_first_stream(self):
+ actual = shuffle(
+ self.selected_streams,
+ "tap_stream_id",
+ "stream1",
+ sort_function=sort_selected_streams
+ )
+ expected = [
+ {"tap_stream_id": "stream1"},
+ {"tap_stream_id": "stream2"},
+ {"tap_stream_id": "stream3"},
+ {"tap_stream_id": "stream4"},
+ {"tap_stream_id": "stream5"},
+ ]
+ self.assertListEqual(expected, actual)
+
+
+ def test_shuffle_middle_stream(self):
+ actual = shuffle(
+ self.selected_streams,
+ "tap_stream_id",
+ "stream3",
+ sort_function=sort_selected_streams
+ )
+ expected = [
+ {"tap_stream_id": "stream3"},
+ {"tap_stream_id": "stream4"},
+ {"tap_stream_id": "stream5"},
+ {"tap_stream_id": "stream1"},
+ {"tap_stream_id": "stream2"},
+ ]
+ self.assertListEqual(expected, actual)
+
+ def test_shuffle_last_stream(self):
+ actual = shuffle(
+ self.selected_streams,
+ "tap_stream_id",
+ "stream5",
+ sort_function=sort_selected_streams
+ )
+ expected = [
+ {"tap_stream_id": "stream5"},
+ {"tap_stream_id": "stream1"},
+ {"tap_stream_id": "stream2"},
+ {"tap_stream_id": "stream3"},
+ {"tap_stream_id": "stream4"},
+ ]
+ self.assertListEqual(expected, actual)
+
+ def test_shuffle_deselect_currently_syncing(self):
+ actual = shuffle(
+ [
+ {"tap_stream_id": "stream1"},
+ {"tap_stream_id": "stream2"},
+ {"tap_stream_id": "stream4"},
+ {"tap_stream_id": "stream5"},
+ ],
+ "tap_stream_id",
+ "stream3",
+ sort_function=sort_selected_streams
+ )
+ expected = [
+ {"tap_stream_id": "stream4"},
+ {"tap_stream_id": "stream5"},
+ {"tap_stream_id": "stream1"},
+ {"tap_stream_id": "stream2"},
+
+ ]
+ self.assertListEqual(expected, actual)
+
+
+class TestShuffleCustomers(unittest.TestCase):
+
+ customers = [
+ {"customerId": "customer1"},
+ {"customerId": "customer2"},
+ {"customerId": "customer3"},
+ {"customerId": "customer4"},
+ {"customerId": "customer5"},
+ ]
+
+ def test_shuffle_first_customer(self):
+ actual = shuffle(
+ self.customers,
+ "customerId",
+ "customer1",
+ sort_function=sort_customers
+ )
+ expected = [
+ {"customerId": "customer1"},
+ {"customerId": "customer2"},
+ {"customerId": "customer3"},
+ {"customerId": "customer4"},
+ {"customerId": "customer5"},
+ ]
+ self.assertListEqual(expected, actual)
+
+ def test_shuffle_middle_customer(self):
+ actual = shuffle(
+ self.customers,
+ "customerId",
+ "customer3",
+ sort_function=sort_customers
+ )
+ expected = [
+ {"customerId": "customer3"},
+ {"customerId": "customer4"},
+ {"customerId": "customer5"},
+ {"customerId": "customer1"},
+ {"customerId": "customer2"},
+ ]
+ self.assertListEqual(expected, actual)
+
+ def test_shuffle_last_customer(self):
+ actual = shuffle(
+ self.customers,
+ "customerId",
+ "customer5",
+ sort_function=sort_customers
+ )
+ expected = [
+ {"customerId": "customer5"},
+ {"customerId": "customer1"},
+ {"customerId": "customer2"},
+ {"customerId": "customer3"},
+ {"customerId": "customer4"},
+ ]
+ self.assertListEqual(expected, actual)
+
if __name__ == '__main__':
unittest.main()
From 8ce42bebe7f2c81073dcf5886c9e5063f1abed5f Mon Sep 17 00:00:00 2001
From: Andy Lu
Date: Mon, 14 Mar 2022 16:06:49 -0400
Subject: [PATCH 27/69] Bump to v0.2.0, update changelog (#31)
* Bump to v0.2.0, update changelog
* Add link for this PR, fix link syntax
* Update changelog format
---
CHANGELOG.md | 5 +++++
setup.py | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a2da4c..b184ef8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v0.2.0 [#31](https://github.com/singer-io/tap-google-ads/pull/31)
+ * Add ability for the tap to use `currently_syncing` [#24](https://github.com/singer-io/tap-google-ads/pull/24)
+ * Add `end_date` as a configurable property to end a sync at a certain date [#28](https://github.com/singer-io/tap-google-ads/pull/28)
+ * Fix a field exculsion bug introduced in `v0.1.0` around metric compatibility [#29](https://github.com/singer-io/tap-google-ads/pull/29)
+
## v0.1.0 [#23](https://github.com/singer-io/tap-google-ads/pull/23)
* Update bookmarks to only be written with a midnight time value
* Fix error logging to more concise
diff --git a/setup.py b/setup.py
index d6b3498..ebdd870 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='0.1.0',
+ version='0.2.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From 5b032dd804f3ae32204beec996df60031246039d Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Wed, 16 Mar 2022 14:17:34 -0400
Subject: [PATCH 28/69] Qa/configurable props (#32)
* add conversion window test
* add conversion window test
* wip updated tests to worka with currently syncing dev branch [skip ci]
* Revert removal of metric compatibility removal (#29)
* Revert removal of metric compatibility removal
* Whitespace cleanup
* Add currently syncing (#24)
* Sync all customers for a given stream
* Add logging to see when we retry requests
* Update currently_syncing with customerId too. Write state as soon as we
update it
* Add the customerId to the bookmark keys
* Add shuffle for customerId and tap_stream_id; add shuffle unit tests
* Bug fix for when currently_syncing is null
* Fix exception handling typeError
* Fix none cases for currently_syncing
* Fix currently_syncing to write a tuple we can read in later
* Add get_customer_ids so we can use it in the tests
* Fix manipulated_state to account for customer_ids
* Update assertion for currently_syncing
* Fix currently syncing assertion
* Move bookmark access into Full Table assertions section
Full Table doesn't need the "stream_name and customer id" key logic
* Remove duplicate assertion
* Revert 6db016e7ec29c2b00973b671c1efdf9451aca9c2
* Update bookmark to read stream->customer->replication_key
* Update tap to write bookmarks as stream->customer->replication_key
* Update manipulated state to nest stream->customer->replication_key
* Run bookmark assertions for every customer
* Fix dict comprehension typo
* Fix conflict with main
* Remove `get_state_key` again, use env var instead of hardcoded value
* Add missing dependency
* Move currently-syncing-null-out to the end of sync to prevent gaps
* Sort selected_streams and customers to guarantee consistency across runs
* Don't let the tap write (None, None)
* Sort selected_streams and customers effectively
* Update currently_syncing test assertions
* Add sort functions for streams and customers
* Update `shuffle` to handle a missing value
* Update unit tests to use sort_function, add a test for shuffling streams
* Add end date (#28)
* Add optional end date, add unit tests
Co-authored-by: Andy Lu
* Test functions can't be named run_test apparently
* Rename do_thing
* Extract `get_queries_from_sync` as a function
* Remove unused variable
* Refactor tests to be more explicit
* Mock singer.utils.now to return a specific date
Co-authored-by: Andy Lu
* add conversion_window test
* fixed conversion window unittests, bug removed
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: Andy Lu
Co-authored-by: kspeer
* Bump to v0.2.0, update changelog (#31)
* Bump to v0.2.0, update changelog
* Add link for this PR, fix link syntax
* Update changelog format
* expanded conversion window testing for error case, BUG linked
* parallelism 8 -> 12
* added unittest for start date within conversion window
Co-authored-by: kspeer
Co-authored-by: Dylan <28106103+dsprayberry@users.noreply.github.com>
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: Andy Lu
---
.circleci/config.yml | 2 +-
tests/test_google_ads_conversion_window.py | 120 ++++++++++++++++
...st_google_ads_conversion_window_invalid.py | 131 ++++++++++++++++++
tests/unittests/test_conversion_window.py | 117 +++++++++++++---
4 files changed, 350 insertions(+), 20 deletions(-)
create mode 100644 tests/test_google_ads_conversion_window.py
create mode 100644 tests/test_google_ads_conversion_window_invalid.py
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 7179ce9..f1386b6 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -69,7 +69,7 @@ jobs:
run_integration_tests:
executor: docker-executor
- parallelism: 8
+ parallelism: 12
steps:
- checkout
- attach_workspace:
diff --git a/tests/test_google_ads_conversion_window.py b/tests/test_google_ads_conversion_window.py
new file mode 100644
index 0000000..88e83b1
--- /dev/null
+++ b/tests/test_google_ads_conversion_window.py
@@ -0,0 +1,120 @@
+"""Test tap configurable properties. Specifically the conversion_window"""
+import os
+from datetime import datetime as dt
+from datetime import timedelta
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class ConversionWindowBaseTest(GoogleAdsBase):
+ """
+ Test tap's sync mode can execute with valid conversion_window values set.
+
+ Validate setting the conversion_window configurable property.
+
+ Test Cases:
+
+ Verify tap throws critical error when a value is provided directly by a user which is
+ outside the set of acceptable values.
+
+ Verify connection can be created, and tap can discover and sync with a conversion window
+ set to the following values
+ Acceptable values: { 1 through 30, 60, 90}
+ """
+ conversion_window = ''
+
+ def name(self):
+ return f"tt_google_ads_conv_window_{self.conversion_window}"
+
+ def get_properties(self):
+ """Configurable properties, with a switch to override the 'start_date' property"""
+ return {
+ 'start_date': dt.strftime(dt.utcnow() - timedelta(days=91), self.START_DATE_FORMAT),
+ 'user_id': 'not used?', # TODO ?
+ 'customer_ids': ','.join(self.get_customer_ids()),
+ 'conversion_window': self.conversion_window,
+ 'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ "loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
+ }
+
+ def run_test(self):
+ """
+ Testing that basic sync functions without Critical Errors when
+ a valid conversion_windown is set.
+ """
+ print("Configurable Properties Test (conversion_window)")
+
+ streams_to_test = {
+ 'campagins',
+ 'account_performance_report',
+ }
+
+ # Create a connection
+ conn_id = connections.ensure_connection(self)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ # Perform table and field selection...
+ core_catalogs = [catalog for catalog in found_catalogs
+ if not self.is_report(catalog['stream_name'])
+ and catalog['stream_name'] in streams_to_test]
+ report_catalogs = [catalog for catalog in found_catalogs
+ if self.is_report(catalog['stream_name'])
+ and catalog['stream_name'] in streams_to_test]
+ # select all fields for core streams and...
+ self.select_all_streams_and_fields(conn_id, core_catalogs, select_all_fields=True)
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs)
+
+ # set state to ensure conversion window is used
+ today_datetime = dt.strftime(dt.utcnow(), self.REPLICATION_KEY_FORMAT)
+ customer_id = os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID')
+ initial_state = {
+ 'bookmarks': {stream: {customer_id: {'date': today_datetime}}
+ for stream in streams_to_test
+ if self.is_report(stream)}
+ }
+ menagerie.set_state(conn_id, initial_state)
+
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ # Verify the tap and target do not throw a critical error
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # Verify tap replicates through today by check state
+ final_state = menagerie.get_state(conn_id)
+ self.assertDictEqual(final_state, initial_state)
+
+
+class ConversionWindowTestOne(ConversionWindowBaseTest):
+
+ conversion_window = '1'
+
+ def test_run(self):
+ self.run_test()
+
+class ConversionWindowTestThirty(ConversionWindowBaseTest):
+
+ conversion_window = '30'
+
+ def test_run(self):
+ self.run_test()
+
+class ConversionWindowTestSixty(ConversionWindowBaseTest):
+
+ conversion_window = '60'
+
+ def test_run(self):
+ self.run_test()
+
+class ConversionWindowTestNinety(ConversionWindowBaseTest):
+
+ conversion_window = '90'
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/test_google_ads_conversion_window_invalid.py b/tests/test_google_ads_conversion_window_invalid.py
new file mode 100644
index 0000000..32928e7
--- /dev/null
+++ b/tests/test_google_ads_conversion_window_invalid.py
@@ -0,0 +1,131 @@
+"""Test tap configurable properties. Specifically the conversion_window"""
+import os
+import unittest
+from datetime import datetime as dt
+from datetime import timedelta
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class ConversionWindowInvalidTest(GoogleAdsBase):
+ """
+ Test tap's sync mode can execute with valid conversion_window values set.
+
+ Validate setting the conversion_window configurable property.
+
+ Test Cases:
+
+ Verify tap throws critical error when a value is provided directly by a user which is
+ outside the set of acceptable values.
+
+ Verify connection can be created, and tap can discover and sync with a conversion window
+ set to the following values
+ Acceptable values: { 1 through 30, 60, 90}
+ """
+ conversion_window = ''
+
+ def name(self):
+ return f"tt_googleads_conversion_invalid_{self.conversion_window}"
+
+ def get_properties(self):
+ """Configurable properties, with a switch to override the 'start_date' property"""
+ return {
+ 'start_date': dt.strftime(dt.utcnow() - timedelta(days=91), self.START_DATE_FORMAT),
+ 'user_id': 'not used?', # TODO ?
+ 'customer_ids': ','.join(self.get_customer_ids()),
+ 'conversion_window': self.conversion_window,
+ 'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ "loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
+ }
+
+ def run_test(self):
+ """
+ Testing that basic sync functions without Critical Errors when
+ a valid conversion_windown is set.
+ """
+ print("Configurable Properties Test (conversion_window)")
+
+ streams_to_test = {
+ 'campagins',
+ 'account_performance_report',
+ }
+
+ try:
+ # Create a connection
+ conn_id = connections.ensure_connection(self)
+
+ with self.subTest():
+ raise AssertionError(f"Conenction should not have been created with conversion_window: "
+ f"value {self.conversion_window}, type {type(self.conversion_window)}")
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ # Perform table and field selection...
+ core_catalogs = [catalog for catalog in found_catalogs
+ if not self.is_report(catalog['stream_name'])
+ and catalog['stream_name'] in streams_to_test]
+ report_catalogs = [catalog for catalog in found_catalogs
+ if self.is_report(catalog['stream_name'])
+ and catalog['stream_name'] in streams_to_test]
+ # select all fields for core streams and...
+ self.select_all_streams_and_fields(conn_id, core_catalogs, select_all_fields=True)
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs)
+
+ # set state to ensure conversion window is used
+ today_datetime = dt.strftime(dt.utcnow(), self.REPLICATION_KEY_FORMAT)
+ customer_id = os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID')
+ initial_state = {
+ 'bookmarks': {stream: {customer_id: {'date': today_datetime}}
+ for stream in streams_to_test
+ if self.is_report(stream)}
+ }
+ menagerie.set_state(conn_id, initial_state)
+
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ # Verify the tap and target throw a critical error
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # Verify tap replicates through today by check state
+ final_state = menagerie.get_state(conn_id)
+ self.assertDictEqual(final_state, initial_state)
+
+ with self.subTest():
+ raise AssertionError("Tap should not have ran sync with conversion_window: "
+ f"value {self.conversion_window}, type {type(self.conversion_window)}")
+
+ except Exception as ex:
+ err_msg_1 = "'message': 'properties do not match schema'"
+ err_msg_2 = "'bad_properties': ['conversion_window']"
+
+ print("Expected exception occurred.")
+
+ # Verify connection cannot be made with invalid conversion_window
+ print(f"Validating error message contains {err_msg_1}")
+ self.assertIn(err_msg_1, ex.args[0])
+ print(f"Validating error message contains {err_msg_2}")
+ self.assertIn(err_msg_2, ex.args[0])
+
+
+class ConversionWindowTestZeroInteger(ConversionWindowInvalidTest):
+
+ conversion_window = 0
+
+ @unittest.skip("https://jira.talendforge.org/browse/TDL-18168"
+ "[tap-google-ads] Invalid conversion_window values can be set when running tap directly")
+ def test_run(self):
+ self.run_test()
+
+
+class ConversionWindowTestZeroString(ConversionWindowInvalidTest):
+
+ conversion_window = '0'
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/unittests/test_conversion_window.py b/tests/unittests/test_conversion_window.py
index 6b8d039..4e5dac4 100644
--- a/tests/unittests/test_conversion_window.py
+++ b/tests/unittests/test_conversion_window.py
@@ -33,26 +33,31 @@ def test_bookmark_within_90_day_conversion_window(self, fake_make_request):
self.execute(conversion_window, fake_make_request)
def execute(self, conversion_window, fake_make_request):
- start_date = datetime(2021, 12, 1, 0, 0, 0)
- # Create the stream so we can call sync
- my_report_stream = ReportStream(
- fields=[],
- google_ads_resource_names=['accessible_bidding_strategy'],
- resource_schema=resource_schema,
- primary_keys=['foo']
- )
+ # Set config using conversion_window under test
+ start_date = datetime(2021, 12, 1, 0, 0, 0)
config = {
"start_date": str(start_date),
"conversion_window": str(conversion_window),
}
end_date = datetime.now()
- bookmark_value = str(end_date - timedelta(days=(conversion_window - 5)))
+ # Set state to fall inside {today - conversion_window}
+ bookmark_value = str(end_date - timedelta(days=(conversion_window - 5)))
state = {
"currently_syncing": (None, None),
"bookmarks": {"hi": {"123": {'date': bookmark_value}},}
}
+
+ # Create the stream so we can call sync
+ my_report_stream = ReportStream(
+ fields=[],
+ google_ads_resource_names=['accessible_bidding_strategy'],
+ resource_schema=resource_schema,
+ primary_keys=['foo']
+ )
+
+ # Execute sync directly and record requests made for stream
my_report_stream.sync(
Mock(),
{"customerId": "123",
@@ -63,7 +68,6 @@ def execute(self, conversion_window, fake_make_request):
config,
state,
)
-
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
# The function signature is gas, query, customer_id
@@ -79,6 +83,7 @@ def execute(self, conversion_window, fake_make_request):
# Verify the number of days queried is based off the conversion window.
self.assertEqual(len(all_queries_requested), conversion_window + 1) # inclusive
+
class TestBookmarkOnConversionWindow(unittest.TestCase):
@patch('tap_google_ads.streams.make_request')
@@ -102,7 +107,21 @@ def test_bookmark_on_90_day_conversion_window(self, fake_make_request):
self.execute(conversion_window, fake_make_request)
def execute(self, conversion_window, fake_make_request):
+
+ # Set config using conversion_window under test
start_date = datetime(2021, 12, 1, 0, 0, 0)
+ config = {
+ "start_date": str(start_date),
+ "conversion_window": str(conversion_window),
+ }
+ end_date = datetime.now()
+
+ # Set state to fall on the conversion_window date
+ bookmark_value = str(end_date - timedelta(days=conversion_window))
+ state = {
+ "currently_syncing": (None, None),
+ "bookmarks": {"hi": {"123": {'date': bookmark_value}},}
+ }
# Create the stream so we can call sync
my_report_stream = ReportStream(
@@ -111,17 +130,78 @@ def execute(self, conversion_window, fake_make_request):
resource_schema=resource_schema,
primary_keys=['foo']
)
+
+ # Execute sync directly and record requests made for stream
+ my_report_stream.sync(
+ Mock(),
+ {"customerId": "123",
+ "loginCustomerId": "456"},
+ {"tap_stream_id": "hi",
+ "stream": "hi",
+ "metadata": []},
+ config,
+ state,
+ )
+ all_queries_requested = []
+ for request_sent in fake_make_request.call_args_list:
+ # The function signature is gas, query, customer_id
+ _, query, _ = request_sent.args
+ all_queries_requested.append(query)
+
+
+ # Verify the first date queried is the conversion window date / bookmark
+ expected_first_query_date = str(bookmark_value)[:10]
+ actual_first_query_date = str(all_queries_requested[0])[-11:-1]
+ self.assertEqual(expected_first_query_date, actual_first_query_date)
+
+ # Verify the number of days queried is based off the conversion window.
+ self.assertEqual(len(all_queries_requested), conversion_window + 1) # inclusive
+
+
+class TestStartDateWithinConversionWindow(unittest.TestCase):
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_1_day_conversion_window(self, fake_make_request):
+ conversion_window = 1
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_default_conversion_window(self, fake_make_request):
+ conversion_window = 30
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_60_day_conversion_window(self, fake_make_request):
+ conversion_window = 60
+ self.execute(conversion_window, fake_make_request)
+
+ @patch('tap_google_ads.streams.make_request')
+ def test_bookmark_on_90_day_conversion_window(self, fake_make_request):
+ conversion_window = 90
+ self.execute(conversion_window, fake_make_request)
+
+ def execute(self, conversion_window, fake_make_request):
+
+ # Set config using conversion_window under test
+ start_date = datetime.now().replace(hour=0, minute=0, second=0)
config = {
"start_date": str(start_date),
"conversion_window": str(conversion_window),
}
end_date = datetime.now()
- bookmark_value = str(end_date - timedelta(days=conversion_window))
- state = {
- "currently_syncing": (None, None),
- "bookmarks": {"hi": {"123": {'date': bookmark_value}},}
- }
+ # Set state to empty
+ state = {}
+
+ # Create the stream so we can call sync
+ my_report_stream = ReportStream(
+ fields=[],
+ google_ads_resource_names=['accessible_bidding_strategy'],
+ resource_schema=resource_schema,
+ primary_keys=['foo']
+ )
+
+ # Execute sync directly and record requests made for stream
my_report_stream.sync(
Mock(),
{"customerId": "123",
@@ -132,7 +212,6 @@ def execute(self, conversion_window, fake_make_request):
config,
state,
)
-
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
# The function signature is gas, query, customer_id
@@ -141,12 +220,12 @@ def execute(self, conversion_window, fake_make_request):
# Verify the first date queried is the conversion window date (not the bookmark)
- expected_first_query_date = str(bookmark_value)[:10]
+ expected_first_query_date = str(start_date)[:10]
actual_first_query_date = str(all_queries_requested[0])[-11:-1]
self.assertEqual(expected_first_query_date, actual_first_query_date)
- # Verify the number of days queried is based off the conversion window.
- self.assertEqual(len(all_queries_requested), conversion_window + 1) # inclusive
+ # Verify the number of days queried is based off the start_date
+ self.assertEqual(len(all_queries_requested), 1)
if __name__ == '__main__':
From 4c57419acd0f2862a2daad155eda1ea8ad81d35f Mon Sep 17 00:00:00 2001
From: bhtowles
Date: Fri, 18 Mar 2022 10:09:11 -0500
Subject: [PATCH 29/69] Qa/coverage (#30)
* WIP expanding stream coverage
* WIP canary test
* WIP start date test
* WIP expanding stream coverage
* WIP canary test
* WIP start date test
* Increased start date and bookmakrks tests
* descrease runtime and skipping record count check in exlcusion test
* fix imports for invalid selection test
* Sync all customers for a given stream
* Add logging to see when we retry requests
* Update currently_syncing with customerId too. Write state as soon as we
update it
* Add the customerId to the bookmark keys
* Add shuffle for customerId and tap_stream_id; add shuffle unit tests
* Bug fix for when currently_syncing is null
* Fix state key to separate stream name and customer id
* Fix currently_syncing to write a tuple we can read in later
* Add get_customer_ids so we can use it in the tests
* Fix manipulated_state to account for customer_ids
* Update assertion for currently_syncing
* Fix currently syncing assertion
* Move bookmark access into Full Table assertions section
Full Table doesn't need the "stream_name and customer id" key logic
* Remove duplicate assertion
* Revert 6db016e7ec29c2b00973b671c1efdf9451aca9c2
* Update bookmark to read stream->customer->replication_key
* Update tap to write bookmarks as stream->customer->replication_key
* Update manipulated state to nest stream->customer->replication_key
* Run bookmark assertions for every customer
* Fix dict comprehension typo
* Qa/exclusion completion (#26)
* Adding mutual field exclusion tests
* Updates to passing exclusion tests
* Clean up
* Updates from PR review
Co-authored-by: btowles
* Increased start date and bookmakrks tests
* WIP expanding stream coverage
* WIP canary test
* WIP start date test
* Added interrupted sync state lite
* Start two sync approach
* Scenario 1 passing interrupted sync test
* Peer review changes and added stream scenario
* 4 interrupted sync tests passing
* cleanup persisted conflicts in exclusion tests
* PR feedback addressed
* bump parallelism 8 -> 14
* Review changes to interrupted sync, base, and start date tests
* fix collision in test namespaces
* Next round of review chagnes for interrupted state tests
* Added assertion for comparing 'yet-to-be-synced' records against the full uninterrupted sync
* fix print line record count
Co-authored-by: kspeer
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
Co-authored-by: btowles
---
.circleci/config.yml | 2 +-
tests/base.py | 81 +++--
tests/test_google_ads_bookmarks.py | 28 +-
tests/test_google_ads_field_exclusion.py | 90 +++--
...test_google_ads_field_exclusion_invalid.py | 7 +-
tests/test_google_ads_interrupted_sync.py | 236 ++++++++++++++
..._google_ads_interrupted_sync_add_stream.py | 237 ++++++++++++++
...terrupted_sync_remove_currently_syncing.py | 249 ++++++++++++++
...ogle_ads_interrupted_sync_remove_stream.py | 250 ++++++++++++++
tests/test_google_ads_start_date.py | 16 +-
tests/test_google_ads_sync_canary.py | 307 +-----------------
11 files changed, 1112 insertions(+), 391 deletions(-)
create mode 100644 tests/test_google_ads_interrupted_sync.py
create mode 100644 tests/test_google_ads_interrupted_sync_add_stream.py
create mode 100644 tests/test_google_ads_interrupted_sync_remove_currently_syncing.py
create mode 100644 tests/test_google_ads_interrupted_sync_remove_stream.py
diff --git a/.circleci/config.yml b/.circleci/config.yml
index f1386b6..6a0f25e 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -69,7 +69,7 @@ jobs:
run_integration_tests:
executor: docker-executor
- parallelism: 12
+ parallelism: 16
steps:
- checkout
- attach_workspace:
diff --git a/tests/base.py b/tests/base.py
index d411b43..1c3412b 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -617,13 +617,8 @@ def expected_default_fields():
'view_through_conversions', # View-through conv.,
},
"ad_group_audience_performance_report": {
- 'average_cpc', # Avg. CPC,
- 'average_cpm', # Avg. CPM
- 'clicks', # Clicks,
- 'ctr', # CTR,
- 'customer_id', # Customer ID,
- 'impressions', # Impr.,
- 'ad_group_targeting_setting', # Targeting Setting,
+ 'ad_group_name',
+ 'user_list_name',
},
"campaign_performance_report": {
'average_cpc', # Avg. CPC,
@@ -656,16 +651,17 @@ def expected_default_fields():
'user_list',
},
"display_keyword_performance_report": { # TODO NO DATA AVAILABLE
- 'average_cpc', # Avg. CPC,
- 'average_cpm', # Avg. CPM,
- 'average_cpv', # Avg. CPV,
+ 'ad_group_name',
+ # 'average_cpc', # Avg. CPC,
+ # 'average_cpm', # Avg. CPM,
+ # 'average_cpv', # Avg. CPV,
'clicks', # Clicks,
- 'conversions', # Conversions,
- 'cost_per_conversion', # Cost / conv.,
+ # 'conversions', # Conversions,
+ # 'cost_per_conversion', # Cost / conv.,
'impressions', # Impr.,
- 'interaction_rate', # Interaction rate,
- 'interactions', # Interactions,
- 'view_through_conversions', # View-through conv.,
+ # 'interaction_rate', # Interaction rate,
+ # 'interactions', # Interactions,
+ # 'view_through_conversions', # View-through conv.,
},
"display_topics_performance_report": { # TODO NO DATA AVAILABLE
'ad_group_name', # 'ad_group', # Ad group,
@@ -679,15 +675,26 @@ def expected_default_fields():
},
"placement_performance_report": { # TODO NO DATA AVAILABLE
'clicks',
- 'impressions', # Impr.,
+ 'impressions',
+ 'ad_group_id',
'ad_group_criterion_placement', # 'placement_group', 'placement_type',
},
- # "keywords_performance_report": set(),
+ "keywords_performance_report": { # TODO NO DATA AVAILABLE
+ 'campaign_id',
+ 'clicks',
+ 'impressions',
+ 'ad_group_criterion_keyword',
+ },
+ "keywordless_query_report": {
+ 'campaign_id',
+ 'clicks',
+ 'impressions',
+ },
# "shopping_performance_report": set(),
"video_performance_report": {
'campaign_name',
'clicks',
- 'video_quartile_p25_rate',
+ # 'video_views',
},
# NOTE AFTER THIS POINT COULDN"T FIND IN UI
"account_performance_report": {
@@ -756,6 +763,40 @@ def expected_default_fields():
'interactions',
'placeholder_type',
},
- # 'landing_page_report': set(), # TODO
- # 'expanded_landing_page_report': set(), # TODO
+ 'user_location_performance_report': {
+ 'campaign_id',
+ 'clicks',
+ 'geo_target_region',
+ },
+ 'landing_page_report': {
+ 'ad_group_name',
+ 'campaign_name',
+ 'clicks',
+ 'average_cpc',
+ 'unexpanded_final_url',
+ },
+ 'expanded_landing_page_report': {
+ 'ad_group_name',
+ 'campaign_name',
+ 'clicks',
+ 'average_cpc',
+ 'expanded_final_url',
+ },
+ 'campaign_audience_performance_report': {
+ 'campaign_name',
+ 'click_type',
+ 'clicks',
+ 'interactions',
+ },
}
+ def assertIsDateFormat(self, value, str_format):
+ """
+ Assertion Method that verifies a string value is a formatted datetime with
+ the specified format.
+ """
+ try:
+ _ = dt.strptime(value, str_format)
+ except ValueError as err:
+ raise AssertionError(
+ f"Value does not conform to expected format: {str_format}"
+ ) from err
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index 84a814e..23a7998 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -49,14 +49,10 @@ def test_run(self):
'ad_group_audience_performance_report',
'display_keyword_performance_report',
'display_topics_performance_report',
- 'expanded_landing_page_report',
- 'keywordless_query_report',
'keywords_performance_report',
- 'landing_page_report',
'placement_performance_report',
'search_query_performance_report',
'shopping_performance_report',
- 'user_location_performance_report',
'video_performance_report',
'campaign_audience_performance_report',
}
@@ -91,16 +87,20 @@ def test_run(self):
data_set_state_value_1 = '2022-01-24T00:00:00.000000Z'
data_set_state_value_2 = '2021-12-30T00:00:00.000000Z'
injected_state_by_stream = {
- 'ad_group_performance_report':data_set_state_value_1,
- 'geo_performance_report':data_set_state_value_1,
- 'gender_performance_report':data_set_state_value_1,
- 'placeholder_feed_item_report':data_set_state_value_2,
- 'age_range_performance_report':data_set_state_value_1,
- 'account_performance_report':data_set_state_value_1,
- 'click_performance_report':data_set_state_value_1,
- 'campaign_performance_report':data_set_state_value_1,
- 'placeholder_report':data_set_state_value_2,
- 'ad_performance_report':data_set_state_value_1,
+ 'ad_group_performance_report': data_set_state_value_1,
+ 'geo_performance_report': data_set_state_value_1,
+ 'gender_performance_report': data_set_state_value_1,
+ 'placeholder_feed_item_report': data_set_state_value_2,
+ 'age_range_performance_report': data_set_state_value_1,
+ 'account_performance_report': data_set_state_value_1,
+ 'click_performance_report': data_set_state_value_1,
+ 'campaign_performance_report': data_set_state_value_1,
+ 'placeholder_report': data_set_state_value_2,
+ 'ad_performance_report': data_set_state_value_1,
+ 'expanded_landing_page_report': data_set_state_value_1,
+ 'keywordless_query_report': data_set_state_value_1,
+ 'landing_page_report': data_set_state_value_1,
+ 'user_location_performance_report': data_set_state_value_1,
}
manipulated_state = {
diff --git a/tests/test_google_ads_field_exclusion.py b/tests/test_google_ads_field_exclusion.py
index 01ddd06..596111a 100644
--- a/tests/test_google_ads_field_exclusion.py
+++ b/tests/test_google_ads_field_exclusion.py
@@ -1,4 +1,6 @@
"""Test tap field exclusions with random field selection."""
+from datetime import datetime as dt
+from datetime import timedelta
import random
from tap_tester import menagerie, connections, runner
@@ -13,7 +15,6 @@ class FieldExclusionGoogleAds(GoogleAdsBase):
NOTE: Manual test case must be run at least once any time this feature changes or is updated.
Verify when given field selected, `fieldExclusions` fields in metadata are grayed out and cannot be selected (Manually)
"""
-
@staticmethod
def name():
return "tt_google_ads_field_exclusion"
@@ -61,26 +62,23 @@ def test_default_case(self):
streams_to_test = {stream for stream in self.expected_streams()
if self.is_report(stream)} - {'click_performance_report'} # No exclusions
- streams_to_test = streams_to_test - {
- # These streams missing from expected_default_fields() method TODO unblocked due to random? Test them now
- # 'expanded_landing_page_report',
- # 'shopping_performance_report',
- # 'user_location_performance_report',
- # 'keywordless_query_report',
- # 'keywords_performance_report',
- # 'landing_page_report',
- # TODO These streams have no data to replicate and fail the last assertion
- 'video_performance_report',
- 'audience_performance_report',
- 'placement_performance_report',
- 'display_topics_performance_report',
- 'display_keyword_performance_report',
- }
-
- #streams_to_test = {'gender_performance_report', 'placeholder_report',}
-
+ # streams_to_test = streams_to_test - {
+ # # These streams missing from expected_default_fields() method TODO unblocked due to random? Test them now
+ # # 'shopping_performance_report',
+ # # 'keywords_performance_report',
+ # # TODO These streams have no data to replicate and fail the last assertion
+ # 'video_performance_report',
+ # 'audience_performance_report',
+ # 'placement_performance_report',
+ # 'display_topics_performance_report',
+ # 'display_keyword_performance_report',
+ # }
+ # streams_to_test = {'gender_performance_report', 'placeholder_report',}
random_order_of_exclusion_fields = {}
- conn_id = connections.ensure_connection(self)
+
+ # bump start date from default
+ self.start_date = dt.strftime(dt.today() - timedelta(days=3), self.START_DATE_FORMAT)
+ conn_id = connections.ensure_connection(self, original_properties=False)
# Run a discovery job
found_catalogs = self.run_and_verify_check_mode(conn_id)
@@ -154,32 +152,32 @@ def test_default_case(self):
# These streams likely replicate records using the default field selection but may not produce any
# records when selecting this many fields with exclusions.
- streams_unlikely_to_replicate_records = {
- 'ad_performance_report',
- 'account_performance_report',
- 'shopping_performance_report',
- 'search_query_performance_report',
- 'placeholder_feed_item_report',
- 'placeholder_report',
- 'keywords_performance_report',
- 'keywordless_query_report',
- 'geo_performance_report',
- 'gender_performance_report', # Very rare
- 'ad_group_audience_performance_report',
- 'age_range_performance_report',
- 'campaign_audience_performance_report',
- 'user_location_performance_report',
- 'ad_group_performance_report',
- }
-
- if stream not in streams_unlikely_to_replicate_records:
- sync_record_count = runner.examine_target_output_file(
- self, conn_id, self.expected_streams(), self.expected_primary_keys())
- self.assertGreater(
- sum(sync_record_count.values()), 0,
- msg="failed to replicate any data: {}".format(sync_record_count)
- )
- print("total replicated row count: {}".format(sum(sync_record_count.values())))
+ # streams_unlikely_to_replicate_records = {
+ # 'ad_performance_report',
+ # 'account_performance_report',
+ # 'shopping_performance_report',
+ # 'search_query_performance_report',
+ # 'placeholder_feed_item_report',
+ # 'placeholder_report',
+ # 'keywords_performance_report',
+ # 'keywordless_query_report',
+ # 'geo_performance_report',
+ # 'gender_performance_report', # Very rare
+ # 'ad_group_audience_performance_report',
+ # 'age_range_performance_report',
+ # 'campaign_audience_performance_report',
+ # 'user_location_performance_report',
+ # 'ad_group_performance_report',
+ # }
+
+ # if stream not in streams_unlikely_to_replicate_records:
+ # sync_record_count = runner.examine_target_output_file(
+ # self, conn_id, self.expected_streams(), self.expected_primary_keys())
+ # self.assertGreater(
+ # sum(sync_record_count.values()), 0,
+ # msg="failed to replicate any data: {}".format(sync_record_count)
+ # )
+ # print("total replicated row count: {}".format(sum(sync_record_count.values())))
# TODO additional assertions?
diff --git a/tests/test_google_ads_field_exclusion_invalid.py b/tests/test_google_ads_field_exclusion_invalid.py
index fd04f35..3816935 100644
--- a/tests/test_google_ads_field_exclusion_invalid.py
+++ b/tests/test_google_ads_field_exclusion_invalid.py
@@ -1,6 +1,8 @@
"""Test tap field exclusions for invalid selection sets."""
import random
import pprint
+from datetime import datetime as dt
+from datetime import timedelta
from tap_tester import menagerie, connections, runner
@@ -113,7 +115,10 @@ def test_invalid_case(self):
random_order_of_exclusion_fields = {}
tap_exit_status_by_stream = {}
exclusion_errors = {}
- conn_id = connections.ensure_connection(self)
+
+ # bump start date from default
+ self.start_date = dt.strftime(dt.today() - timedelta(days=3), self.START_DATE_FORMAT)
+ conn_id = connections.ensure_connection(self, original_properties=False)
# Run a discovery job
found_catalogs = self.run_and_verify_check_mode(conn_id)
diff --git a/tests/test_google_ads_interrupted_sync.py b/tests/test_google_ads_interrupted_sync.py
new file mode 100644
index 0000000..d3e8137
--- /dev/null
+++ b/tests/test_google_ads_interrupted_sync.py
@@ -0,0 +1,236 @@
+import os
+from datetime import datetime as dt
+from datetime import timedelta
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class InterruptedSyncTest(GoogleAdsBase):
+ """Test tap's ability to recover from an interrupted sync"""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_interruption"
+
+ def get_properties(self, original: bool = True):
+ """Configurable properties, with a switch to override the 'start_date' property"""
+ return_value = {
+ 'start_date': '2022-01-22T00:00:00Z',
+ 'user_id': 'not used?', # TODO ?
+ 'customer_ids': ','.join(self.get_customer_ids()),
+ 'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ "loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
+ }
+
+ # TODO_TDL-17911 Add a test around conversion_window_days
+ if original:
+ return return_value
+
+ self.start_date = return_value['start_date']
+ return return_value
+
+
+ def test_run(self):
+ """
+ Scenario: A sync job is interrupted. The state is saved with `currently_syncing`.
+ The next sync job kicks off and the tap picks back up on that `currently_syncing` stream.
+
+ Expected State Structure:
+ state = {'currently_syncing': ('', ''),
+ 'bookmarks': {
+ '': {'': {'': }},
+ '': {'': {'': }},
+
+ Test Cases:
+ - Verify an interrupted sync can resume based on the `currently_syncing` and stream level bookmark value.
+ - Verify only records with replication-key values greater than or equal to the stream level bookmark are
+ replicated on the resuming sync for the interrupted stream.
+ - Verify the yet-to-be-synced streams are replicated following the interrupted stream in the resuming sync.
+ (All yet-to-be-synced streams must replicate before streams that were already synced. - covered by unittests) TODO verify with devs
+
+ NOTE: The following streams all had records for the dates used in this test. If needed they can be used in
+ testing cases like this in the future.
+ 'ad_group_performance_report', 'ad_performance_report', 'age_range_performance_report',
+ 'campaign_performance_report', 'click_performance_report', 'expanded_landing_page_report',
+ 'gender_performance_report', 'geo_performance_report', 'keywordless_query_report', 'landing_page_report',
+ """
+
+ print("Interrupted Sync Test for tap-google-ads")
+
+ # the following streams are under test as they all have 4 consecutive days with records e.g.
+ # ('2022-01-22T00:00:00.000000Z', '2022-01-23T00:00:00.000000Z', '2022-01-24T00:00:00.000000Z', '2022-01-25T00:00:00.000000Z')])}
+ streams_under_test = {
+ 'ads',
+ 'account_performance_report',
+ 'search_query_performance_report',
+ 'user_location_performance_report',
+ }
+
+ # Create connection using a recent start date
+ conn_id = connections.ensure_connection(self, original_properties=False)
+
+ # Run a discovery job
+ found_catalogs_1 = self.run_and_verify_check_mode(conn_id)
+
+ # partition catalogs for use in table/field seelction
+ test_catalogs_1 = [catalog for catalog in found_catalogs_1
+ if catalog.get('stream_name') in streams_under_test]
+ core_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if not self.is_report(catalog['stream_name'])]
+ report_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if self.is_report(catalog['stream_name'])]
+
+ # select all fields for core streams
+ self.select_all_streams_and_fields(conn_id, core_catalogs_1, select_all_fields=True)
+
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs_1)
+
+ # Run a sync
+ full_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ full_sync_records = runner.get_records_from_target_output()
+ full_sync_state = menagerie.get_state(conn_id)
+
+ """
+ NB | Set state such that all but two streams have 'completed' a sync. The final stream ('user_location_performance_report') should
+ have no bookmark value while the interrupted stream ('search_query_performance_report') should have a bookmark value prior to the
+ 'completed' streams.
+ (These dates are the most recent where data exists before and after the manipulated bookmarks for each stream.)
+ """
+ completed_bookmark_value = '2022-01-24T00:00:00.000000Z'
+ interrupted_bookmark_value = '2022-01-23T00:00:00.000000Z'
+ interrupted_state = {
+ 'currently_syncing': ('search_query_performance_report', '5548074409'),
+ 'bookmarks': {
+ 'account_performance_report': {'5548074409': {'date': completed_bookmark_value}},
+ 'search_query_performance_report': {'5548074409': {'date': interrupted_bookmark_value}},
+ },
+ }
+
+ menagerie.set_state(conn_id, interrupted_state)
+
+ # Run another sync
+ interrupted_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ interrupted_sync_records = runner.get_records_from_target_output()
+ final_state = menagerie.get_state(conn_id)
+ currently_syncing = final_state.get('currently_syncing')
+
+ # Checking resuming sync resulted in successfully saved state
+ with self.subTest():
+
+ # Verify sync is not interrupted by checking currently_syncing in state for sync
+ self.assertIsNone(currently_syncing)
+
+ # Verify bookmarks are saved
+ self.assertIsNotNone(final_state.get('bookmarks'))
+
+ # Verify final_state is equal to uninterrupted sync's state
+ # (This is what the value would have been without an interruption and proves resuming succeeds)
+ self.assertDictEqual(final_state, full_sync_state)
+
+ # stream-level assertions
+ for stream in streams_under_test:
+ with self.subTest(stream=stream):
+
+ # set expectations
+ expected_replication_method = self.expected_replication_method()[stream]
+ conversion_window = timedelta(days=30) # defaulted value
+ today_datetime = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) # TODO should this be moved for test stability?
+
+ # gather results
+ full_records = [message['data'] for message in full_sync_records[stream]['messages']]
+ full_record_count = len(full_records)
+ interrupted_records = [message['data'] for message in interrupted_sync_records[stream]['messages']]
+ interrupted_record_count = len(interrupted_records)
+
+ if expected_replication_method == self.INCREMENTAL:
+
+ # gather expectations
+ expected_primary_key = list(self.expected_primary_keys()[stream])[0]
+ expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
+ testable_customer_ids = set(self.get_customer_ids()) - {'2728292456'} # TODO before finalizing all tests make a standard for ref these
+ for customer in testable_customer_ids:
+ with self.subTest(customer_id=customer):
+
+ # gather results
+ start_date_datetime = dt.strptime(self.start_date, self.START_DATE_FORMAT)
+ oldest_record_datetime = dt.strptime(interrupted_records[0].get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ final_stream_bookmark = final_state['bookmarks'][stream]
+ final_bookmark = final_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ final_bookmark_datetime = dt.strptime(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ # Verify final bookmark saved match formatting standards for resuming sync
+ self.assertIsNotNone(final_bookmark)
+ self.assertIsInstance(final_bookmark, str)
+ self.assertIsDateFormat(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ if stream in interrupted_state['bookmarks'].keys():
+
+ interrupted_stream_bookmark = interrupted_state['bookmarks'][stream]
+ interrupted_bookmark = interrupted_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ interrupted_bookmark_datetime = dt.strptime(interrupted_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ # Verify resuming sync replicates records inclusively
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, interrupted_bookmark_datetime)
+
+ # Verify resuming sync only replicates records with replication key values greater or equal to
+ # the interrupted_state for streams that were replicated during the interrupted sync.
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(rec_time, interrupted_bookmark_datetime)
+
+ # Verify the interrupted sync replicates the expected record set
+ # All interrupted recs are in full recs
+ for record in interrupted_records:
+ self.assertIn(record, full_records, msg='incremental table record in interrupted sync not found in full sync')
+
+ # Record count for all streams of interrupted sync match expectations
+ full_records_after_interrupted_bookmark = 0
+ for record in full_records:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ if rec_time >= interrupted_bookmark_datetime:
+ full_records_after_interrupted_bookmark += 1
+ self.assertEqual(full_records_after_interrupted_bookmark, len(interrupted_records), \
+ msg="Expected {} records in each sync".format(full_records_after_interrupted_bookmark))
+
+ else:
+
+ # Verify resuming sync replicates records starting with start date for streams that were yet-to-be-synced
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, start_date_datetime)
+
+ # Verify resuming sync replicates all records that were found in the full sync (uninterupted)
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ self.assertIn(record, full_records, msg='Unexpected record replicated in resuming sync.')
+ for record in full_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ self.assertIn(record, interrupted_records, msg='Record missing from resuming sync.' )
+
+
+ # Verify the bookmark is set based on sync end date (today) for resuming sync
+ self.assertEqual(final_bookmark_datetime, today_datetime)
+
+ elif expected_replication_method == self.FULL_TABLE:
+
+ # Verify full table streams do not save bookmarked values at the conclusion of a succesful sync
+ self.assertNotIn(stream, full_sync_state['bookmarks'].keys())
+ self.assertNotIn(stream, final_state['bookmarks'].keys())
+
+ # Verify first and second sync have the same records
+ self.assertEqual(full_record_count, interrupted_record_count)
+ for rec in interrupted_records:
+ self.assertIn(rec, full_records, msg='full table record in interrupted sync not found in full sync')
+
+ # Verify at least 1 record was replicated for each stream
+ self.assertGreater(interrupted_record_count, 0)
+
+ print(f"{stream} resumed sync records replicated: {interrupted_record_count}")
diff --git a/tests/test_google_ads_interrupted_sync_add_stream.py b/tests/test_google_ads_interrupted_sync_add_stream.py
new file mode 100644
index 0000000..edb2c8c
--- /dev/null
+++ b/tests/test_google_ads_interrupted_sync_add_stream.py
@@ -0,0 +1,237 @@
+import os
+from datetime import datetime as dt
+from datetime import timedelta
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class InterruptedSyncAddStreamTest(GoogleAdsBase):
+ """Test tap's ability to recover from an interrupted sync"""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_interruption_add"
+
+ def get_properties(self, original: bool = True):
+ """Configurable properties, with a switch to override the 'start_date' property"""
+ return_value = {
+ 'start_date': '2022-01-22T00:00:00Z',
+ 'user_id': 'not used?', # TODO ?
+ 'customer_ids': ','.join(self.get_customer_ids()),
+ 'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ "loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
+
+ }
+
+ # TODO_TDL-17911 Add a test around conversion_window_days
+ if original:
+ return return_value
+
+ self.start_date = return_value["start_date"]
+ return return_value
+
+
+ def test_run(self):
+ """
+ Scenario: A sync job is interrupted. The state is saved with `currently_syncing`.
+ The next sync job kicks off and the tap picks back up on that `currently_syncing` stream.
+
+ Expected State Structure:
+ state = {'currently_syncing': ('', ''),
+ 'bookmarks': {
+ '': {'': {'': }},
+ '': {'': {'': }},
+
+ Test Cases:
+ - Verify behavior is consistent when an added stream is selected between initial and resuming sync
+ - Verify an interrupted sync can resume based on the `currently_syncing` and stream level bookmark value
+ - Verify only records with replication-key values greater than or equal to the stream level bookmark are replicated on the resuming sync for the interrupted stream
+ - Verify the yet-to-be-synced streams are replicated following the interrupted stream in the resuming sync. All yet-to-be-synced streams must replicate before streams that were already synced.
+ """
+ print("Interrupted Sync Test for tap-google-ads with added stream")
+
+ # the following streams are under test as they all have 4 consecutive days with records e.g.
+ # ('2022-01-23T00:00:00.000000Z', '2022-01-23T00:00:00.000000Z', '2022-01-24T00:00:00.000000Z', '2022-01-25T00:00:00.000000Z')])}
+ streams_under_test = {'ads',
+ 'account_performance_report',
+ 'search_query_performance_report',
+ 'user_location_performance_report',
+ }
+
+ # Create connection using a recent start date
+ conn_id = connections.ensure_connection(self, original_properties=False)
+
+ # Run a discovery job
+ found_catalogs_1 = self.run_and_verify_check_mode(conn_id)
+
+ # partition catalogs for use in table/field seelction
+ test_catalogs_1 = [catalog for catalog in found_catalogs_1
+ if catalog.get('stream_name') in streams_under_test]
+ core_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if not self.is_report(catalog['stream_name'])]
+ report_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if self.is_report(catalog['stream_name'])]
+
+ # select all fields for core streams
+ self.select_all_streams_and_fields(conn_id, core_catalogs_1, select_all_fields=True)
+
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs_1)
+
+ # Run a sync
+ full_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ full_sync_records = runner.get_records_from_target_output()
+ full_sync_state = menagerie.get_state(conn_id)
+
+ # Add a stream between syncs
+ added_stream = 'ad_performance_report'
+ streams_under_test.add(added_stream)
+ added_stream_catalog = [catalog for catalog in found_catalogs_1
+ if catalog.get('stream_name') == added_stream]
+
+ # add new stream to selected list
+ self.select_all_streams_and_default_fields(conn_id, added_stream_catalog)
+
+ # NB | Set state such that all but two streams have 'completed' a sync. The final stream ('user_location_performance_report') should
+ # have no bookmark value while the interrupted stream ('search_query_performance_report') should have a bookmark value prior to the
+ # 'completed' streams.
+ # (These dates are the most recent where data exists before and after the manipulated bookmarks for each stream.)
+ completed_bookmark_value = '2022-01-24T00:00:00.000000Z'
+ interrupted_bookmark_value = '2022-01-23T00:00:00.000000Z'
+ interrupted_state = {
+ 'currently_syncing': ('search_query_performance_report', '5548074409'),
+ 'bookmarks': {
+ 'account_performance_report': {'5548074409': {'date': completed_bookmark_value}},
+ 'search_query_performance_report': {'5548074409': {'date': interrupted_bookmark_value}},
+ },
+ }
+
+ menagerie.set_state(conn_id, interrupted_state)
+
+ # Run another sync
+ interrupted_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ interrupted_sync_records = runner.get_records_from_target_output()
+ final_state = menagerie.get_state(conn_id)
+ currently_syncing = final_state.get('currently_syncing')
+
+ # Checking resuming sync resulted in successfully saved state
+ with self.subTest():
+
+ # Verify sync is not interrupted by checking currently_syncing in state for sync 1
+ self.assertIsNone(currently_syncing)
+
+ # Verify bookmarks are saved
+ self.assertIsNotNone(final_state.get('bookmarks'))
+
+ # stream-level assertions
+ for stream in streams_under_test:
+ with self.subTest(stream=stream):
+
+ # set expectations
+ expected_replication_method = self.expected_replication_method()[stream]
+ conversion_window = timedelta(days=30) # defaulted value
+ today_datetime = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+
+ # gather results
+ if stream != added_stream:
+ full_records = [message['data'] for message in full_sync_records[stream]['messages']]
+ full_record_count = len(full_records)
+ interrupted_records = [message['data'] for message in interrupted_sync_records[stream]['messages']]
+ interrupted_record_count = len(interrupted_records)
+
+ if expected_replication_method == self.INCREMENTAL:
+
+ # gather expectations
+ expected_primary_key = list(self.expected_primary_keys()[stream])[0]
+ expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
+ testable_customer_ids = set(self.get_customer_ids()) - {'2728292456'}
+ for customer in testable_customer_ids:
+ with self.subTest(customer_id=customer):
+
+ # gather results
+ start_date_datetime = dt.strptime(self.start_date, self.START_DATE_FORMAT)
+ oldest_record_datetime = dt.strptime(interrupted_records[0].get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ final_stream_bookmark = final_state['bookmarks'][stream]
+ final_bookmark = final_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ final_bookmark_datetime = dt.strptime(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ # Verify final bookmark saved match formatting standards for resuming sync
+ self.assertIsNotNone(final_bookmark)
+ self.assertIsInstance(final_bookmark, str)
+ self.assertIsDateFormat(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ if stream in full_sync_state['bookmarks'].keys():
+ full_sync_stream_bookmark = full_sync_state['bookmarks'][stream]
+ full_sync_bookmark = full_sync_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ full_sync_bookmark_datetime = dt.strptime(full_sync_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ if stream in interrupted_state['bookmarks'].keys():
+ interrupted_stream_bookmark = interrupted_state['bookmarks'][stream]
+ interrupted_bookmark = interrupted_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ interrupted_bookmark_datetime = dt.strptime(interrupted_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ # Verify resuming sync replicates records inclusively
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, interrupted_bookmark_datetime)
+
+ # Verify resuming sync only replicates records with replication key values greater or equal to
+ # the interrupted_state for streams that completed were replicated during the interrupted sync.
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(rec_time, interrupted_bookmark_datetime)
+
+ # Record count for all streams of interrupted sync match expectations
+ full_records_after_interrupted_bookmark = 0
+ for record in full_records:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+
+ if rec_time >= interrupted_bookmark_datetime:
+ full_records_after_interrupted_bookmark += 1
+ self.assertEqual(full_records_after_interrupted_bookmark, len(interrupted_records), \
+ msg="Expected {} records in each sync".format(full_records_after_interrupted_bookmark))
+ else:
+
+ self.assertGreater(len(interrupted_records), 0)
+
+ if stream != added_stream:
+
+ # Verify state ends with the same value for common streams after both full and interrupted syncs
+ self.assertEqual(full_sync_bookmark_datetime, final_bookmark_datetime)
+
+ # Verify the interrupted sync replicates the expected record set
+ # All interrupted recs are in full recs
+ for record in interrupted_records:
+ self.assertIn(record, full_records, msg='incremental table record in interrupted sync not found in full sync')
+
+ else:
+
+ # Verify resuming sync replicates records starting with start date for streams that were yet-to-be-synced
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, start_date_datetime)
+
+ # Verify the bookmark is set based on sync end date (today) for resuming sync
+ self.assertEqual(final_bookmark_datetime, today_datetime)
+
+
+ elif expected_replication_method == self.FULL_TABLE:
+
+ # Verify full table streams do not save bookmarked values at the conclusion of a succesful sync
+ self.assertNotIn(stream, full_sync_state['bookmarks'].keys())
+ self.assertNotIn(stream, final_state['bookmarks'].keys())
+
+ # Verify first and second sync have the same records
+ self.assertEqual(full_record_count, interrupted_record_count)
+ for rec in interrupted_records:
+ self.assertIn(rec, full_records, msg='full table record in interrupted sync not found in full sync')
+
+ # Verify at least 1 record was replicated for each stream
+ self.assertGreater(interrupted_record_count, 0)
+
+ print(f"{stream} resumed sync records replicated: {interrupted_record_count}")
diff --git a/tests/test_google_ads_interrupted_sync_remove_currently_syncing.py b/tests/test_google_ads_interrupted_sync_remove_currently_syncing.py
new file mode 100644
index 0000000..bea583d
--- /dev/null
+++ b/tests/test_google_ads_interrupted_sync_remove_currently_syncing.py
@@ -0,0 +1,249 @@
+import os
+from datetime import datetime as dt
+from datetime import timedelta
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class InterruptedSyncRemoveStreamTest(GoogleAdsBase):
+ """Test tap's ability to recover from an interrupted sync"""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_remove_currently_syncing"
+
+ def get_properties(self, original: bool = True):
+ """Configurable properties, with a switch to override the 'start_date' property"""
+ return_value = {
+ 'start_date': '2022-01-22T00:00:00Z',
+ 'user_id': 'not used?', # TODO ?
+ 'customer_ids': ','.join(self.get_customer_ids()),
+ 'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ "loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
+ }
+
+ # TODO_TDL-17911 Add a test around conversion_window_days
+ if original:
+ return return_value
+
+ self.start_date = return_value["start_date"]
+ return return_value
+
+
+ def test_run(self):
+ """
+ Scenario: A sync job is interrupted. The state is saved with `currently_syncing`.
+ The next sync job kicks off and the tap picks back up on that `currently_syncing` stream.
+
+ Expected State Structure:
+ state = {'currently_syncing': ('', ''),
+ 'bookmarks': {
+ '': {'': {'': }},
+ '': {'': {'': }},
+
+ Test Cases:
+ - Verify behavior is consistent when a stream is removed from selected list between initial and resuming sync
+ - Verify an interrupted sync can resume based on the `currently_syncing` and stream level bookmark value
+ - Verify only records with replication-key values greater than or equal to the stream level bookmark are replicated on the resuming sync for the interrupted stream
+ - Verify the yet-to-be-synced streams are replicated following the interrupted stream in the resuming sync. All yet-to-be-synced streams must replicate before streams that were already synced.
+ """
+ print("Interrupted Sync Test for tap-google-ads with added stream")
+
+ # the following streams are under test as they all have 4 consecutive days with records e.g.
+ # ('2022-01-23T00:00:00.000000Z', '2022-01-23T00:00:00.000000Z', '2022-01-24T00:00:00.000000Z', '2022-01-25T00:00:00.000000Z')])}
+ streams_under_test = {'ads',
+ 'account_performance_report',
+ 'search_query_performance_report',
+ 'user_location_performance_report',
+ }
+
+ # Create connection using a recent start date
+ conn_id = connections.ensure_connection(self, original_properties=False)
+
+ # Run a discovery job
+ found_catalogs_1 = self.run_and_verify_check_mode(conn_id)
+
+ # partition catalogs for use in table/field seelction
+ test_catalogs_1 = [catalog for catalog in found_catalogs_1
+ if catalog.get('stream_name') in streams_under_test]
+ core_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if not self.is_report(catalog['stream_name'])]
+ report_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if self.is_report(catalog['stream_name'])]
+
+ # select all fields for core streams
+ self.select_all_streams_and_fields(conn_id, core_catalogs_1, select_all_fields=True)
+
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs_1)
+
+ # Run a sync
+ full_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ full_sync_records = runner.get_records_from_target_output()
+ full_sync_state = menagerie.get_state(conn_id)
+
+ # Remove the currently_syncing stream between syncs
+ removed_stream = 'search_query_performance_report'
+ deselect_catalog = [catalog for catalog in test_catalogs_1
+ if catalog.get('stream_name') == removed_stream]
+
+ # de-select desired stream
+ self.deselect_streams(conn_id, deselect_catalog)
+
+ # NB | Set state such that all but two streams have 'completed' a sync. The final stream ('user_location_performance_report') should
+ # have no bookmark value while the interrupted stream ('search_query_performance_report') should have a bookmark value prior to the
+ # 'completed' streams.
+ # (These dates are the most recent where data exists before and after the manipulated bookmarks for each stream.)
+ completed_bookmark_value = '2022-01-24T00:00:00.000000Z'
+ interrupted_bookmark_value = '2022-01-23T00:00:00.000000Z'
+ interrupted_state = {
+ 'currently_syncing': ('search_query_performance_report', '5548074409'),
+ 'bookmarks': {
+ 'account_performance_report': {'5548074409': {'date': completed_bookmark_value}},
+ 'search_query_performance_report': {'5548074409': {'date': interrupted_bookmark_value}},
+ },
+ }
+
+ menagerie.set_state(conn_id, interrupted_state)
+
+ # Run another sync
+ interrupted_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ interrupted_sync_records = runner.get_records_from_target_output()
+ final_state = menagerie.get_state(conn_id)
+ currently_syncing = final_state.get('currently_syncing')
+
+ # Checking resuming sync resulted in successfully saved state
+ with self.subTest():
+
+ # Verify sync is not interrupted by checking currently_syncing in state for sync 1
+ self.assertIsNone(currently_syncing)
+
+ # Verify bookmarks are saved
+ self.assertIsNotNone(final_state.get('bookmarks'))
+
+ # stream-level assertions
+ for stream in streams_under_test:
+ with self.subTest(stream=stream):
+
+ # set expectations
+ expected_replication_method = self.expected_replication_method()[stream]
+ conversion_window = timedelta(days=30) # defaulted value
+ today_datetime = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+
+ # gather results
+ full_records = [message['data'] for message in full_sync_records[stream]['messages']]
+ full_record_count = len(full_records)
+ if stream != removed_stream:
+ interrupted_records = [message['data'] for message in interrupted_sync_records[stream]['messages']]
+ interrupted_record_count = len(interrupted_records)
+ else:
+ # Verify resuming sync does not sync records for removed_stream
+ self.assertNotIn(removed_stream, interrupted_sync_records.keys())
+
+ if expected_replication_method == self.INCREMENTAL:
+
+ # gather expectations
+ expected_primary_key = list(self.expected_primary_keys()[stream])[0]
+ expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
+ testable_customer_ids = set(self.get_customer_ids()) - {'2728292456'}
+
+ for customer in testable_customer_ids:
+ with self.subTest(customer_id=customer):
+
+ # gather results
+ start_date_datetime = dt.strptime(self.start_date, self.START_DATE_FORMAT)
+ if stream != removed_stream:
+ oldest_record_datetime = dt.strptime(interrupted_records[0].get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ final_stream_bookmark = final_state['bookmarks'][stream]
+ final_bookmark = final_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ final_bookmark_datetime = dt.strptime(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ # Verify final bookmark saved match formatting standards for resuming sync
+ self.assertIsNotNone(final_bookmark)
+ self.assertIsInstance(final_bookmark, str)
+ self.assertIsDateFormat(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ full_sync_stream_bookmark = full_sync_state['bookmarks'][stream]
+ full_sync_bookmark = full_sync_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ full_sync_bookmark_datetime = dt.strptime(full_sync_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ if stream in interrupted_state['bookmarks'].keys():
+
+ interrupted_stream_bookmark = interrupted_state['bookmarks'][stream]
+ interrupted_bookmark = interrupted_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ interrupted_bookmark_datetime = dt.strptime(interrupted_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ if stream != removed_stream:
+ # Verify state ends with the same value for common streams after both full and interrupted syncs
+ self.assertEqual(full_sync_bookmark_datetime, final_bookmark_datetime)
+
+ # Verify resuming sync replicates records inclusively
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, interrupted_bookmark_datetime)
+
+ # Verify resuming sync only replicates records with replication key values greater or equal to
+ # the interrupted_state for streams that completed were replicated during the interrupted sync.
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(rec_time, interrupted_bookmark_datetime)
+
+ # Verify the interrupted sync replicates the expected record set
+ # All interrupted recs are in full recs
+ for record in interrupted_records:
+ self.assertIn(record, full_records, msg='incremental table record in interrupted sync not found in full sync')
+
+ # Record count for all streams of interrupted sync match expectations
+ full_records_after_interrupted_bookmark = 0
+ for record in full_records:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+
+ if rec_time >= interrupted_bookmark_datetime:
+ full_records_after_interrupted_bookmark += 1
+ self.assertEqual(full_records_after_interrupted_bookmark, len(interrupted_records), \
+ msg="Expected {} records in each sync".format(full_records_after_interrupted_bookmark))
+
+ else:
+
+ # Verify resuming sync replicates records starting with start date for streams that were yet-to-be-synced
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, start_date_datetime)
+
+ # Verify resuming sync replicates all records that were found in the full sync (uninterupted)
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ self.assertIn(record, full_records, msg='Unexpected record replicated in resuming sync.')
+ for record in full_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ self.assertIn(record, interrupted_records, msg='Record missing from resuming sync.' )
+
+ if stream != removed_stream:
+ # Verify the bookmark is set based on sync end date (today) for resuming sync
+ self.assertEqual(final_bookmark_datetime, today_datetime)
+ else:
+ # Verify the bookmark has not advance for the removed stream
+ self.assertEqual(final_bookmark_datetime, interrupted_bookmark_datetime)
+
+
+ elif expected_replication_method == self.FULL_TABLE:
+
+ # Verify full table streams do not save bookmarked values at the conclusion of a succesful sync
+ self.assertNotIn(stream, full_sync_state['bookmarks'].keys())
+ self.assertNotIn(stream, final_state['bookmarks'].keys())
+
+ # Verify first and second sync have the same records
+ self.assertEqual(full_record_count, interrupted_record_count)
+ for rec in interrupted_records:
+ self.assertIn(rec, full_records, msg='full table record in interrupted sync not found in full sync')
+
+ # Verify at least 1 record was replicated for each stream
+ if stream != removed_stream:
+ self.assertGreater(interrupted_record_count, 0)
+
+ print(f"{stream} resumed sync records replicated: {interrupted_record_count}")
diff --git a/tests/test_google_ads_interrupted_sync_remove_stream.py b/tests/test_google_ads_interrupted_sync_remove_stream.py
new file mode 100644
index 0000000..b3d893d
--- /dev/null
+++ b/tests/test_google_ads_interrupted_sync_remove_stream.py
@@ -0,0 +1,250 @@
+import os
+from datetime import datetime as dt
+from datetime import timedelta
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class InterruptedSyncRemoveStreamTest(GoogleAdsBase):
+ """Test tap's ability to recover from an interrupted sync"""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_interruption_remove"
+
+ def get_properties(self, original: bool = True):
+ """Configurable properties, with a switch to override the 'start_date' property"""
+ return_value = {
+ 'start_date': '2022-01-22T00:00:00Z',
+ 'user_id': 'not used?', # TODO ?
+ 'customer_ids': ','.join(self.get_customer_ids()),
+ 'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
+ "loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
+ }
+
+ # TODO_TDL-17911 Add a test around conversion_window_days
+ if original:
+ return return_value
+
+ self.start_date = return_value['start_date']
+ return return_value
+
+
+ def test_run(self):
+ """
+ Scenario: A sync job is interrupted. The state is saved with `currently_syncing`.
+ The next sync job kicks off and the tap picks back up on that `currently_syncing` stream.
+
+ Expected State Structure:
+ state = {'currently_syncing': ('', ''),
+ 'bookmarks': {
+ '': {'': {'': }},
+ '': {'': {'': }},
+
+ Test Cases:
+ - Verify behavior is consistent when a stream is removed from selected list between initial and resuming sync
+ - Verify an interrupted sync can resume based on the `currently_syncing` and stream level bookmark value
+ - Verify only records with replication-key values greater than or equal to the stream level bookmark are replicated on the resuming sync for the interrupted stream
+ - Verify the yet-to-be-synced streams are replicated following the interrupted stream in the resuming sync. All yet-to-be-synced streams must replicate before streams that were already synced.
+ """
+ print("Interrupted Sync Test for tap-google-ads with added stream")
+
+ # the following streams are under test as they all have 4 consecutive days with records e.g.
+ # ('2022-01-23T00:00:00.000000Z', '2022-01-23T00:00:00.000000Z', '2022-01-24T00:00:00.000000Z', '2022-01-25T00:00:00.000000Z')])}
+ streams_under_test = {'ads',
+ 'account_performance_report',
+ 'search_query_performance_report',
+ 'user_location_performance_report',
+ }
+
+ # Create connection using a recent start date
+ conn_id = connections.ensure_connection(self, original_properties=False)
+
+ # Run a discovery job
+ found_catalogs_1 = self.run_and_verify_check_mode(conn_id)
+
+ # partition catalogs for use in table/field seelction
+ test_catalogs_1 = [catalog for catalog in found_catalogs_1
+ if catalog.get('stream_name') in streams_under_test]
+ core_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if not self.is_report(catalog['stream_name'])]
+ report_catalogs_1 = [catalog for catalog in test_catalogs_1
+ if self.is_report(catalog['stream_name'])]
+
+ # select all fields for core streams
+ self.select_all_streams_and_fields(conn_id, core_catalogs_1, select_all_fields=True)
+
+ # select 'default' fields for report streams
+ self.select_all_streams_and_default_fields(conn_id, report_catalogs_1)
+
+ # Run a sync
+ full_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ full_sync_records = runner.get_records_from_target_output()
+ full_sync_state = menagerie.get_state(conn_id)
+
+ # Remove a stream between syncs
+ removed_stream = 'account_performance_report'
+ deselect_catalog = [catalog for catalog in test_catalogs_1
+ if catalog.get('stream_name') == removed_stream]
+
+ # de-select desired stream
+ self.deselect_streams(conn_id, deselect_catalog)
+
+ # NB | Set state such that all but two streams have 'completed' a sync. The final stream ('user_location_performance_report') should
+ # have no bookmark value while the interrupted stream ('search_query_performance_report') should have a bookmark value prior to the
+ # 'completed' streams.
+ # (These dates are the most recent where data exists before and after the manipulated bookmarks for each stream.)
+ completed_bookmark_value = '2022-01-24T00:00:00.000000Z'
+ interrupted_bookmark_value = '2022-01-23T00:00:00.000000Z'
+ interrupted_state = {
+ 'currently_syncing': ('search_query_performance_report', '5548074409'),
+ 'bookmarks': {
+ 'account_performance_report': {'5548074409': {'date': completed_bookmark_value}},
+ 'search_query_performance_report': {'5548074409': {'date': interrupted_bookmark_value}},
+ },
+ }
+
+ menagerie.set_state(conn_id, interrupted_state)
+
+ # Run another sync
+ interrupted_sync = self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ interrupted_sync_records = runner.get_records_from_target_output()
+ final_state = menagerie.get_state(conn_id)
+ currently_syncing = final_state.get('currently_syncing')
+
+ # Checking resuming sync resulted in successfully saved state
+ with self.subTest():
+
+ # Verify sync is not interrupted by checking currently_syncing in state for sync 1
+ self.assertIsNone(currently_syncing)
+
+ # Verify bookmarks are saved
+ self.assertIsNotNone(final_state.get('bookmarks'))
+
+ # stream-level assertions
+ for stream in streams_under_test:
+ with self.subTest(stream=stream):
+
+ # set expectations
+ expected_replication_method = self.expected_replication_method()[stream]
+ conversion_window = timedelta(days=30) # defaulted value
+ today_datetime = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+
+ # gather results
+ full_records = [message['data'] for message in full_sync_records[stream]['messages']]
+ full_record_count = len(full_records)
+ if stream != removed_stream:
+ interrupted_records = [message['data'] for message in interrupted_sync_records[stream]['messages']]
+ interrupted_record_count = len(interrupted_records)
+ else:
+ # Verify resuming sync does not sync records for removed stream
+ self.assertNotIn(removed_stream, interrupted_sync_records.keys())
+
+ if expected_replication_method == self.INCREMENTAL:
+
+ # gather expectations
+ expected_primary_key = list(self.expected_primary_keys()[stream])[0]
+ expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
+ testable_customer_ids = set(self.get_customer_ids()) - {'2728292456'}
+
+ for customer in testable_customer_ids:
+ with self.subTest(customer_id=customer):
+
+ # gather results
+ start_date_datetime = dt.strptime(self.start_date, self.START_DATE_FORMAT)
+ if stream != removed_stream:
+ oldest_record_datetime = dt.strptime(interrupted_records[0].get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ final_stream_bookmark = final_state['bookmarks'][stream]
+ final_bookmark = final_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ final_bookmark_datetime = dt.strptime(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ # Verify final bookmark saved match formatting standards for resuming sync
+ self.assertIsNotNone(final_bookmark)
+ self.assertIsInstance(final_bookmark, str)
+ self.assertIsDateFormat(final_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ full_sync_stream_bookmark = full_sync_state['bookmarks'][stream]
+ full_sync_bookmark = full_sync_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ full_sync_bookmark_datetime = dt.strptime(full_sync_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ if stream in interrupted_state['bookmarks'].keys():
+
+ interrupted_stream_bookmark = interrupted_state['bookmarks'][stream]
+ interrupted_bookmark = interrupted_stream_bookmark.get(customer, {}).get(expected_replication_key)
+ interrupted_bookmark_datetime = dt.strptime(interrupted_bookmark, self.REPLICATION_KEY_FORMAT)
+
+ if stream != removed_stream:
+ # Verify state ends with the same value for common streams after both full and interrupted syncs
+ self.assertEqual(full_sync_bookmark_datetime, final_bookmark_datetime)
+
+ # Verify resuming sync replicates records inclusively
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, interrupted_bookmark_datetime)
+
+ # Verify resuming sync only replicates records with replication key values greater or equal to
+ # the interrupted_state for streams that completed were replicated during the interrupted sync.
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+ self.assertGreaterEqual(rec_time, interrupted_bookmark_datetime)
+
+ # Verify the interrupted sync replicates the expected record set
+ # All interrupted recs are in full recs
+ for record in interrupted_records:
+ self.assertIn(record, full_records, msg='incremental table record in interrupted sync not found in full sync')
+
+ # Record count for all streams of interrupted sync match expectations
+ full_records_after_interrupted_bookmark = 0
+ for record in full_records:
+ rec_time = dt.strptime(record.get(expected_replication_key), self.REPLICATION_KEY_FORMAT)
+
+ if rec_time >= interrupted_bookmark_datetime:
+ full_records_after_interrupted_bookmark += 1
+ self.assertEqual(full_records_after_interrupted_bookmark, len(interrupted_records), \
+ msg="Expected {} records in each sync".format(full_records_after_interrupted_bookmark))
+
+ else:
+
+ # Verify resuming sync replicates records starting with start date for streams that were yet-to-be-synced
+ # by comparing the replication key-values to the interrupted state.
+ self.assertEqual(oldest_record_datetime, start_date_datetime)
+
+ # Verify resuming sync replicates all records that were found in the full sync (uninterupted)
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ self.assertIn(record, full_records, msg='Unexpected record replicated in resuming sync.')
+ for record in full_records:
+ with self.subTest(record_primary_key=record[expected_primary_key]):
+ self.assertIn(record, interrupted_records, msg='Record missing from resuming sync.' )
+
+
+ # Verify the bookmark is set based on sync end date (today) for resuming sync
+ if stream != removed_stream:
+ self.assertEqual(final_bookmark_datetime, today_datetime)
+ else:
+ # Verify the bookmark has not advanced for the removed stream
+ self.assertEqual(final_bookmark_datetime, interrupted_bookmark_datetime)
+
+
+ elif expected_replication_method == self.FULL_TABLE:
+
+ # Verify full table streams do not save bookmarked values at the conclusion of a succesful sync
+ self.assertNotIn(stream, full_sync_state['bookmarks'].keys())
+ self.assertNotIn(stream, final_state['bookmarks'].keys())
+
+ # Verify first and second sync have the same records
+ self.assertEqual(full_record_count, interrupted_record_count)
+ for rec in interrupted_records:
+ self.assertIn(rec, full_records, msg='full table record in interrupted sync not found in full sync')
+
+ # Verify at least 1 record was replicated for each stream
+ if stream != removed_stream:
+ self.assertGreater(interrupted_record_count, 0)
+
+ print(f"{stream} resumed sync records replicated: {interrupted_record_count}")
diff --git a/tests/test_google_ads_start_date.py b/tests/test_google_ads_start_date.py
index 3486a05..64b3762 100644
--- a/tests/test_google_ads_start_date.py
+++ b/tests/test_google_ads_start_date.py
@@ -147,18 +147,14 @@ def run_test(self):
class StartDateTest1(StartDateTest):
- missing_coverage_streams = { # end result
- 'display_keyword_performance_report', # no test data available
- 'display_topics_performance_report', # no test data available
- 'placement_performance_report', # no test data available
- "keywords_performance_report", # no test data available
- "keywordless_query_report", # no test data available
- "video_performance_report", # no test data available
+ missing_coverage_streams = { # no test data available
+ 'display_keyword_performance_report',
+ 'display_topics_performance_report',
+ 'placement_performance_report',
+ "keywords_performance_report",
+ "video_performance_report",
'ad_group_audience_performance_report',
"shopping_performance_report",
- 'landing_page_report',
- 'expanded_landing_page_report',
- 'user_location_performance_report',
'campaign_audience_performance_report',
}
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index 0b4dfdf..9618251 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -11,291 +11,6 @@ class SyncCanaryTest(GoogleAdsBase):
with standard table and field selection.
"""
- @staticmethod
- def expected_default_fields():
- """
- In this test core streams have all fields selected.
-
- Report streams will select fields based on the default values that
- are provided when selecting the report type in Google's UI.
-
- returns a dictionary of reports to standard fields
- """
-
- # TODO_TDL-17909 [BUG?] commented out fields below are not discovered for the given stream by the tap
- return {
- 'ad_performance_report': {
- # 'account_name', # 'Account name',
- # 'ad_final_url', # 'Ad final URL',
- # 'ad_group', # 'Ad group',
- # 'ad_mobile_final_url', # 'Ad mobile final URL',
- 'average_cpc', # 'Avg. CPC',
- # 'business_name', # 'Business name',
- # 'call_to_action_text', # 'Call to action text',
- # 'campaign', # 'Campaign',
- # 'campaign_subtype', # 'Campaign type',
- # 'campaign_type', # 'Campaign subtype',
- 'clicks', # 'Clicks',
- 'conversions', # 'Conversions',
- # 'conversion_rate', # 'Conv. rate',
- # 'cost', # 'Cost',
- 'cost_per_conversion', # 'Cost / conv.',
- 'ctr', # 'CTR',
- # 'currency_code', # 'Currency code',
- 'customer_id', # 'Customer ID',
- # 'description', # 'Description',
- # 'description_1', # 'Description 1',
- # 'description_2', # 'Description 2',
- # 'description_3', # 'Description 3',
- # 'description_4', # 'Description 4',
- # 'final_url', # 'Final URL',
- # 'headline_1', # 'Headline 1',
- # 'headline_2', # 'Headline 2',
- # 'headline_3', # 'Headline 3',
- # 'headline_4', # 'Headline 4',
- # 'headline_5', # 'Headline 5',
- 'impressions', # 'Impr.',
- # 'long_headline', # 'Long headline',
- 'view_through_conversions', # 'View-through conv.',
- },
- "ad_group_performance_report": {
- # 'account_name', # Account name,
- # 'ad_group', # Ad group,
- # 'ad_group_state', # Ad group state,
- 'average_cpc', # Avg. CPC,
- # 'campaign', # Campaign,
- # 'campaign_subtype', # Campaign subtype,
- # 'campaign_type', # Campaign type,
- 'clicks', # Clicks,
- # 'conversion_rate', # Conv. rate
- 'conversions', # Conversions,
- # 'cost', # Cost,
- 'cost_per_conversion', # Cost / conv.,
- 'ctr', # CTR,
- # 'currency_code', # Currency code,
- 'customer_id', # Customer ID,
- 'impressions', # Impr.,
- 'view_through_conversions', # View-through conv.,
- },
- # TODO_TDL-17909 | [BUG?] missing audience fields
- "ad_group_audience_performance_report": {
- # 'account_name', # Account name,
- # 'ad_group_name', # 'ad_group', # Ad group,
- # 'ad_group_default_max_cpc', # Ad group default max. CPC,
- # 'audience_segment', # Audience segment,
- # 'audience_segment_bid_adjustments', # Audience Segment Bid adj.,
- # 'audience_segment_max_cpc', # Audience segment max CPC,
- # 'audience_segment_state', # Audience segment state,
- 'average_cpc', # Avg. CPC,
- 'average_cpm', # Avg. CPM
- # 'campaign', # Campaign,
- 'clicks', # Clicks,
- # 'cost', # Cost,
- 'ctr', # CTR,
- # 'currency_code', # Currency code,
- 'customer_id', # Customer ID,
- 'impressions', # Impr.,
- 'ad_group_targeting_setting', # Targeting Setting,
- },
- "campaign_performance_report": {
- # 'account_name', # Account name,
- 'average_cpc', # Avg. CPC,
- # 'campaign', # Campaign,
- # 'campaign_state', # Campaign state,
- # 'campaign_type', # Campaign type,
- 'clicks', # Clicks,
- # 'conversion_rate', # Conv. rate
- 'conversions', # Conversions,
- # 'cost', # Cost,
- 'cost_per_conversion', # Cost / conv.,
- 'ctr', # CTR,
- # 'currency_code', # Currency code,
- 'customer_id', # Customer ID,
- 'impressions', # Impr.,
- 'view_through_conversions', # View-through conv.,
- },
- "click_performance_report": {
- 'ad_group_ad',
- 'ad_group_id',
- 'ad_group_name',
- 'ad_group_status',
- 'ad_network_type',
- 'area_of_interest',
- 'campaign_location_target',
- 'click_type',
- 'clicks',
- 'customer_descriptive_name',
- 'customer_id',
- 'device',
- 'gclid',
- 'location_of_presence',
- 'month_of_year',
- 'page_number',
- 'slot',
- 'user_list',
- },
- "display_keyword_performance_report": { # TODO_TDL-17885 NO DATA AVAILABLE
- # 'ad_group', # Ad group,
- # 'ad_group_bid_strategy_type', # Ad group bid strategy type,
- 'average_cpc', # Avg. CPC,
- 'average_cpm', # Avg. CPM,
- 'average_cpv', # Avg. CPV,
- # 'campaign', # Campaign,
- # 'campaign_bid_strategy_type', # Campaign bid strategy type,
- # 'campaign_subtype', # Campaign subtype,
- 'clicks', # Clicks,
- # 'conversion_rate', # Conv. rate,
- 'conversions', # Conversions,
- # 'cost', # Cost,
- 'cost_per_conversion', # Cost / conv.,
- # 'currency_code', # Currency code,
- # 'display_video_keyword', # Display/video keyword,
- 'impressions', # Impr.,
- 'interaction_rate', # Interaction rate,
- 'interactions', # Interactions,
- 'view_through_conversions', # View-through conv.,
- },
- "display_topics_performance_report": { # TODO_TDL-17885 NO DATA AVAILABLE
- 'ad_group_name', # 'ad_group', # Ad group,
- 'average_cpc', # Avg. CPC,
- 'average_cpm', # Avg. CPM,
- 'campaign_name', # 'campaign', # Campaign,
- 'clicks', # Clicks,
- # 'cost', # Cost,
- 'ctr', # CTR,
- 'customer_currency_code', # 'currency_code', # Currency code,
- 'impressions', # Impr.,
- # 'topic', # Topic,
- # 'topic_state', # Topic state,
- },
- "placement_performance_report": { # TODO_TDL-17885 NO DATA AVAILABLE
- # 'ad_group_name',
- # 'ad_group_id',
- # 'campaign_name',
- # 'campaign_id',
- 'clicks',
- 'impressions', # Impr.,
- # 'cost',
- 'ad_group_criterion_placement', # 'placement_group', 'placement_type',
- },
- # "keywords_performance_report": set(),
- # "shopping_performance_report": set(),
- # BUG
- "video_performance_report": {
- # 'ad_group_name',
- # 'all_conversions',
- # 'all_conversions_from_interactions_rate',
- # 'all_conversions_value',
- # 'average_cpm',
- # 'average_cpv',
- 'campaign_name',
- 'clicks',
- # 'conversions',
- # 'conversions_value',
- # 'cost_per_all_conversions',
- # 'cost_per_conversion',
- # 'customer_descriptive_name',
- # 'customer_id',
- # 'impressions',
- 'video_quartile_p25_rate',
- # 'view_through_conversions',
- },
- # NOTE AFTER THIS POINT COULDN"T FIND IN UI
- "account_performance_report": {
- 'average_cpc',
- 'click_type',
- 'clicks',
- 'date',
- 'descriptive_name',
- 'id',
- 'impressions',
- 'invalid_clicks',
- 'manager',
- 'test_account',
- 'time_zone',
- },
- "geo_performance_report": {
- 'clicks',
- 'ctr', # CTR,
- 'impressions', # Impr.,
- 'average_cpc',
- # 'cost',
- 'conversions',
- 'view_through_conversions', # View-through conv.,
- 'cost_per_conversion', # Cost / conv.,
- # 'conversion_rate', # Conv. rate
- # 'geo_target_city',
- # 'geo_target_metro',
- # 'geo_target_most_specific_location',
- 'geo_target_region',
- # 'country_criterion_id', # TODO_TDL-17910 | [BUG?] PROHIBITED_RESOURCE_TYPE_IN_SELECT_CLAUSE
- },
- "gender_performance_report": {
- # 'account_name', # Account name,
- # 'ad_group', # Ad group,
- # 'ad_group_state', # Ad group state,
- 'ad_group_criterion_gender',
- 'average_cpc', # Avg. CPC,
- # 'campaign', # Campaign,
- # 'campaign_subtype', # Campaign subtype,
- # 'campaign_type', # Campaign type,
- 'clicks', # Clicks,
- # 'conversion_rate', # Conv. rate
- 'conversions', # Conversions,
- # 'cost', # Cost,
- 'cost_per_conversion', # Cost / conv.,
- 'ctr', # CTR,
- # 'currency_code', # Currency code,
- 'customer_id', # Customer ID,
- 'impressions', # Impr.,
- 'view_through_conversions', # View-through conv.,
- },
- "search_query_performance_report": {
- 'clicks',
- 'ctr', # CTR,
- 'impressions', # Impr.,
- 'average_cpc',
- # 'cost',
- 'conversions',
- 'view_through_conversions', # View-through conv.,
- 'cost_per_conversion', # Cost / conv.,
- # 'conversion_rate', # Conv. rate
- 'search_term',
- 'search_term_match_type',
- },
- "age_range_performance_report": {
- 'clicks',
- 'ctr', # CTR,
- 'impressions', # Impr.,
- 'average_cpc',
- # 'cost',
- 'conversions',
- 'view_through_conversions', # View-through conv.,
- 'cost_per_conversion', # Cost / conv.,
- # 'conversion_rate', # Conv. rate
- 'ad_group_criterion_age_range', # 'Age',
- },
- 'placeholder_feed_item_report': {
- 'clicks',
- 'impressions',
- 'placeholder_type',
- },
- 'placeholder_report': {
- # 'ad_group_id',
- # 'ad_group_name',
- # 'average_cpc',
- # 'click_type',
- 'clicks',
- 'cost_micros',
- # 'customer_descriptive_name',
- # 'customer_id',
- 'interactions',
- 'placeholder_type',
- },
- # 'landing_page_report': set(), # TODO_TDL-17885
- # 'expanded_landing_page_report': set(), # TODO_TDL-17885
- }
-
@staticmethod
def name():
return "tt_google_ads_canary"
@@ -312,28 +27,23 @@ def test_run(self):
# TODO_TDL-17885 the following are not yet implemented
'display_keyword_performance_report', # no test data available
'display_topics_performance_report', # no test data available
- 'ad_group_audience_performance_report', # Potential BUG see above
'placement_performance_report', # no test data available
"keywords_performance_report", # no test data available
- "keywordless_query_report", # no test data available
- "shopping_performance_report", # cannot find this in GoogleUI
"video_performance_report", # no test data available
- "user_location_performance_report", # no test data available
- 'landing_page_report', # not attempted
- 'expanded_landing_page_report', # not attempted
- 'campaign_audience_performance_report', # not attempted
+ "shopping_performance_report", # no test data available (need Shopping campaign type)
+ 'campaign_audience_performance_report', # no test data available
+ 'ad_group_audience_performance_report', # Potential BUG see above
}
# Run a discovery job
found_catalogs = self.run_and_verify_check_mode(conn_id)
+ test_catalogs = [catalog for catalog in found_catalogs if catalog['stream_name'] in streams_to_test]
# Perform table and field selection...
- core_catalogs = [catalog for catalog in found_catalogs
- if not self.is_report(catalog['stream_name'])
- and catalog['stream_name'] in streams_to_test]
- report_catalogs = [catalog for catalog in found_catalogs
- if self.is_report(catalog['stream_name'])
- and catalog['stream_name'] in streams_to_test]
+ core_catalogs = [catalog for catalog in test_catalogs
+ if not self.is_report(catalog['stream_name'])]
+ report_catalogs = [catalog for catalog in test_catalogs
+ if self.is_report(catalog['stream_name'])]
# select all fields for core streams and...
self.select_all_streams_and_fields(conn_id, core_catalogs, select_all_fields=True)
# select 'default' fields for report streams
@@ -356,4 +66,3 @@ def test_run(self):
record_count = len(synced_records.get(stream, {'messages': []})['messages'])
self.assertGreater(record_count, 0)
print(f"{record_count} {stream} record(s) replicated.")
-
From fe7cdf23cad804e7266102c2db9a3fffea0de872 Mon Sep 17 00:00:00 2001
From: Andy Lu
Date: Fri, 18 Mar 2022 11:27:27 -0400
Subject: [PATCH 30/69] Conversion window validation (#33)
* Add conversion_window validation
* Consolidate conversion window tests
* Enable invalid conversion window integer test
Co-authored-by: Bryant Gray
---
tap_google_ads/streams.py | 16 ++++++-
...st_google_ads_conversion_window_invalid.py | 2 -
tests/unittests/test_conversion_window.py | 44 +++++++++++++++++++
tests/unittests/test_utils.py | 1 +
4 files changed, 60 insertions(+), 3 deletions(-)
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 7cf2971..c7ab677 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -23,6 +23,20 @@
DEFAULT_CONVERSION_WINDOW = 30
+def get_conversion_window(config):
+ """Fetch the conversion window from the config and error on invalid values"""
+ conversion_window = config.get("conversion_window") or DEFAULT_CONVERSION_WINDOW
+
+ try:
+ conversion_window = int(conversion_window)
+ except (ValueError, TypeError) as err:
+ raise RuntimeError("Conversion Window must be an int or string") from err
+
+ if conversion_window in set(range(1,31)) or conversion_window in {60, 90}:
+ return conversion_window
+
+ raise RuntimeError("Conversion Window must be between 1 - 30 inclusive, 60, or 90")
+
def create_nested_resource_schema(resource_schema, fields):
new_schema = {
"type": ["null", "object"],
@@ -440,7 +454,7 @@ def sync(self, sdk_client, customer, stream, config, state):
singer.write_state(state)
conversion_window = timedelta(
- days=int(config.get("conversion_window") or DEFAULT_CONVERSION_WINDOW)
+ days=get_conversion_window(config)
)
conversion_window_date = utils.now().replace(hour=0, minute=0, second=0, microsecond=0) - conversion_window
diff --git a/tests/test_google_ads_conversion_window_invalid.py b/tests/test_google_ads_conversion_window_invalid.py
index 32928e7..8d4975f 100644
--- a/tests/test_google_ads_conversion_window_invalid.py
+++ b/tests/test_google_ads_conversion_window_invalid.py
@@ -117,8 +117,6 @@ class ConversionWindowTestZeroInteger(ConversionWindowInvalidTest):
conversion_window = 0
- @unittest.skip("https://jira.talendforge.org/browse/TDL-18168"
- "[tap-google-ads] Invalid conversion_window values can be set when running tap directly")
def test_run(self):
self.run_test()
diff --git a/tests/unittests/test_conversion_window.py b/tests/unittests/test_conversion_window.py
index 4e5dac4..2e732af 100644
--- a/tests/unittests/test_conversion_window.py
+++ b/tests/unittests/test_conversion_window.py
@@ -4,6 +4,7 @@
from unittest.mock import MagicMock
from unittest.mock import Mock
from unittest.mock import patch
+from tap_google_ads.streams import get_conversion_window
from tap_google_ads.streams import ReportStream
from tap_google_ads.streams import make_request
@@ -228,5 +229,48 @@ def execute(self, conversion_window, fake_make_request):
self.assertEqual(len(all_queries_requested), 1)
+class TestGetConversionWindow(unittest.TestCase):
+ def test_int_conversion_date_in_allowable_range(self):
+ actual = get_conversion_window({"conversion_window": 12})
+ expected = 12
+ self.assertEqual(expected, actual)
+
+ def test_str_conversion_date_in_allowable_range(self):
+ actual = get_conversion_window({"conversion_window": "12"})
+ expected = 12
+ self.assertEqual(expected, actual)
+
+ def test_conversion_date_outside_allowable_range(self):
+ with self.assertRaises(RuntimeError):
+ get_conversion_window({"conversion_window": 42})
+
+ with self.assertRaises(RuntimeError):
+ get_conversion_window({"conversion_window": "42"})
+
+ def test_non_int_or_str_conversion_date(self):
+ with self.assertRaises(RuntimeError):
+ get_conversion_window({"conversion_window": {"12": 12}})
+
+ with self.assertRaises(RuntimeError):
+ get_conversion_window({"conversion_window": [12]})
+
+ def test_empty_data_types_conversion_date_returns_default(self):
+ expected = 30
+
+ actual = get_conversion_window({"conversion_window": ""})
+ self.assertEqual(expected, actual)
+
+ actual = get_conversion_window({"conversion_window": {}})
+ self.assertEqual(expected, actual)
+
+ actual = get_conversion_window({"conversion_window": []})
+ self.assertEqual(expected, actual)
+
+ def test_None_conversion_date_returns_default(self):
+ actual = get_conversion_window({"conversion_window": None})
+ expected = 30
+ self.assertEqual(expected, actual)
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/unittests/test_utils.py b/tests/unittests/test_utils.py
index 75e2842..6571e6d 100644
--- a/tests/unittests/test_utils.py
+++ b/tests/unittests/test_utils.py
@@ -354,5 +354,6 @@ def test_shuffle_last_customer(self):
]
self.assertListEqual(expected, actual)
+
if __name__ == '__main__':
unittest.main()
From 35da41b172352347862594205070848a7dbb187b Mon Sep 17 00:00:00 2001
From: Andy Lu
Date: Fri, 18 Mar 2022 15:02:54 -0400
Subject: [PATCH 31/69] This field is never compatible with our segments.date
query, so remove it (#34)
Co-authored-by: dylan-stitch <28106103+dylan-stitch@users.noreply.github.com>
---
tap_google_ads/report_definitions.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index 669d359..7f5f803 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -542,7 +542,6 @@
"segments.slot",
"segments.week",
"segments.year",
- "user_list.name",
]
CAMPAIGN_AUDIENCE_PERFORMANCE_REPORT_FIELDS = [
"bidding_strategy.name",
@@ -614,7 +613,6 @@
"segments.slot",
"segments.week",
"segments.year",
- "user_list.name",
]
CAMPAIGN_PERFORMANCE_REPORT_FIELDS = [
"bidding_strategy.name",
From d70e75be7b8cd0dede39f8c84798e43b1a0fcabb Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Tue, 22 Mar 2022 10:32:07 -0400
Subject: [PATCH 32/69] Update to v10 (#35)
* Update client library to 15.0.0 to allow for use of api version 10 and use v10
* Remove resource_name from every response using v10 api parameter
* Fix unittests
---
setup.py | 2 +-
tap_google_ads/discover.py | 2 --
tap_google_ads/streams.py | 15 ++++++++++++---
tests/unittests/test_conversion_window.py | 7 ++++---
4 files changed, 17 insertions(+), 9 deletions(-)
diff --git a/setup.py b/setup.py
index ebdd870..c80439e 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@
'singer-python==5.12.2',
'requests==2.26.0',
'backoff==1.8.0',
- 'google-ads==14.1.0',
+ 'google-ads==15.0.0',
'protobuf==3.17.3',
],
extras_require= {
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
index aaa6646..5a5286d 100644
--- a/tap_google_ads/discover.py
+++ b/tap_google_ads/discover.py
@@ -7,8 +7,6 @@
from tap_google_ads.streams import initialize_core_streams
from tap_google_ads.streams import initialize_reports
-API_VERSION = "v9"
-
LOGGER = singer.get_logger()
REPORTS = [
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index c7ab677..b3c596b 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -12,7 +12,11 @@
LOGGER = singer.get_logger()
-API_VERSION = "v9"
+API_VERSION = "v10"
+
+API_PARAMETERS = {
+ "omit_unselected_resource_names": "true"
+}
REPORTS_WITH_90_DAY_MAX = frozenset(
[
@@ -72,8 +76,13 @@ def get_selected_fields(stream_mdata):
return selected_fields
+def build_parameters():
+ param_str = ",".join(f"{k}={v}" for k, v in API_PARAMETERS.items())
+ return f"PARAMETERS {param_str}"
+
+
def create_core_stream_query(resource_name, selected_fields):
- core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name}"
+ core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} {build_parameters()}"
return core_query
@@ -81,7 +90,7 @@ def create_report_query(resource_name, selected_fields, query_date):
format_str = "%Y-%m-%d"
query_date = utils.strftime(query_date, format_str=format_str)
- report_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} WHERE segments.date = '{query_date}'"
+ report_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} WHERE segments.date = '{query_date}' {build_parameters()}"
return report_query
diff --git a/tests/unittests/test_conversion_window.py b/tests/unittests/test_conversion_window.py
index 2e732af..e1b65a9 100644
--- a/tests/unittests/test_conversion_window.py
+++ b/tests/unittests/test_conversion_window.py
@@ -1,3 +1,4 @@
+import re
import unittest
from datetime import datetime
from datetime import timedelta
@@ -78,7 +79,7 @@ def execute(self, conversion_window, fake_make_request):
# Verify the first date queried is the conversion window date (not the bookmark)
expected_first_query_date = str(end_date - timedelta(days=conversion_window))[:10]
- actual_first_query_date = str(all_queries_requested[0])[-11:-1]
+ actual_first_query_date = re.search(r'\d\d\d\d-\d\d-\d\d', all_queries_requested[0]).group()
self.assertEqual(expected_first_query_date, actual_first_query_date)
# Verify the number of days queried is based off the conversion window.
@@ -152,7 +153,7 @@ def execute(self, conversion_window, fake_make_request):
# Verify the first date queried is the conversion window date / bookmark
expected_first_query_date = str(bookmark_value)[:10]
- actual_first_query_date = str(all_queries_requested[0])[-11:-1]
+ actual_first_query_date = re.search(r'\d\d\d\d-\d\d-\d\d', all_queries_requested[0]).group()
self.assertEqual(expected_first_query_date, actual_first_query_date)
# Verify the number of days queried is based off the conversion window.
@@ -222,7 +223,7 @@ def execute(self, conversion_window, fake_make_request):
# Verify the first date queried is the conversion window date (not the bookmark)
expected_first_query_date = str(start_date)[:10]
- actual_first_query_date = str(all_queries_requested[0])[-11:-1]
+ actual_first_query_date = re.search(r'\d\d\d\d-\d\d-\d\d', all_queries_requested[0]).group()
self.assertEqual(expected_first_query_date, actual_first_query_date)
# Verify the number of days queried is based off the start_date
From 1ec469b6aef0eb477e258df23059ddef4707de6f Mon Sep 17 00:00:00 2001
From: Andy Lu
Date: Thu, 24 Mar 2022 13:43:58 -0400
Subject: [PATCH 33/69] Pre beta bugfixes (#39)
* break out field exlusion tests for parallelism
* remove click_performance_report
* update selection for click_performance_report, failing mutual exlusion case commented out
* [skip ci] WIP on invalid exclusion test
* fix sync canary
* Fix field exclusion
* Remove unused field `selectable`
* Give `_sdc_record_hash` a behavior for consistency
* Fix integration test
* refactor field gatherer, uncomment assertions, test failing
* exlusion tests passing locally
* adding mutual exlusion check to discovery
* Transform `type_` to `type` (#36)
Co-authored-by: Bryant Gray
* Report streams prefix resource names (#37)
* Prepend resource name in the schema
* Prepend resource name in the metadata
* Prepend resource name in the transform_keys
* Prepend resource name in expected_default_fields
Co-authored-by: Bryant Gray
* Change `_sdc_record_hash` to sorted list of tuples (#38)
Co-authored-by: Bryant Gray
* Fix tests
* Fix more tests
Co-authored-by: kspeer
Co-authored-by: Bryant Gray
Co-authored-by: dsprayberry <28106103+dsprayberry@users.noreply.github.com>
---
.circleci/config.yml | 2 +-
tap_google_ads/discover.py | 5 +
tap_google_ads/streams.py | 70 ++++---
tests/base.py | 33 ++-
tests/base_google_ads_field_exclusion.py | 138 +++++++++++++
tests/test_google_ads_discovery.py | 39 ++--
tests/test_google_ads_field_exclusion.py | 186 -----------------
tests/test_google_ads_field_exclusion_1.py | 27 +++
tests/test_google_ads_field_exclusion_2.py | 27 +++
tests/test_google_ads_field_exclusion_3.py | 27 +++
tests/test_google_ads_field_exclusion_4.py | 27 +++
...est_google_ads_field_exclusion_coverage.py | 36 ++++
...test_google_ads_field_exclusion_invalid.py | 190 +++++++-----------
tests/test_google_ads_sync_canary.py | 6 +-
tests/unittests/test_utils.py | 2 +-
15 files changed, 444 insertions(+), 371 deletions(-)
create mode 100644 tests/base_google_ads_field_exclusion.py
delete mode 100644 tests/test_google_ads_field_exclusion.py
create mode 100644 tests/test_google_ads_field_exclusion_1.py
create mode 100644 tests/test_google_ads_field_exclusion_2.py
create mode 100644 tests/test_google_ads_field_exclusion_3.py
create mode 100644 tests/test_google_ads_field_exclusion_4.py
create mode 100644 tests/test_google_ads_field_exclusion_coverage.py
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 6a0f25e..2a5b437 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -69,7 +69,7 @@ jobs:
run_integration_tests:
executor: docker-executor
- parallelism: 16
+ parallelism: 18
steps:
- checkout
- attach_workspace:
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
index 5a5286d..098e5b9 100644
--- a/tap_google_ads/discover.py
+++ b/tap_google_ads/discover.py
@@ -183,6 +183,9 @@ def create_resource_schema(config):
for field in attributes + metrics + segments:
field_schema = dict(resource_schema[field])
+ if field_schema["name"] in segments:
+ field_schema["category"] = "SEGMENT"
+
fields[field_schema["name"]] = {
"field_details": field_schema,
"incompatible_fields": [],
@@ -192,6 +195,8 @@ def create_resource_schema(config):
metrics_and_segments = set(metrics + segments)
for field_name, field in fields.items():
+ if field["field_details"]["category"] == "ATTRIBUTE":
+ continue
for compared_field in metrics_and_segments:
field_root_resource = get_root_resource_name(field_name)
compared_field_root_resource = get_root_resource_name(compared_field)
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index b3c596b..60dd907 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -97,12 +97,12 @@ def create_report_query(resource_name, selected_fields, query_date):
def generate_hash(record, metadata):
metadata = singer.metadata.to_map(metadata)
- fields_to_hash = {}
+ fields_to_hash = []
for key, val in record.items():
if metadata[("properties", key)]["behavior"] != "METRIC":
- fields_to_hash[key] = val
+ fields_to_hash.append((key, val))
- hash_source_data = {key: fields_to_hash[key] for key in sorted(fields_to_hash)}
+ hash_source_data = sorted(fields_to_hash, key=lambda x: x[0])
hash_bytes = json.dumps(hash_source_data).encode("utf-8")
return hashlib.sha256(hash_bytes).hexdigest()
@@ -152,6 +152,18 @@ def make_request(gas, query, customer_id):
return response
+def google_message_to_json(message):
+ """
+ The proto field name for `type` is `type_` which will
+ get stripped by the Transformer. So we replace all
+ instances of the key `"type_"` before `json.loads`ing it
+ """
+
+ json_string = MessageToJson(message, preserving_proto_field_name=True)
+ json_string = json_string.replace('"type_":', '"type":')
+ return json.loads(json_string)
+
+
class BaseStream: # pylint: disable=too-many-instance-attributes
def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys):
@@ -172,7 +184,6 @@ def extract_field_information(self, resource_schema):
self.field_exclusions = defaultdict(set)
self.schema = {}
self.behavior = {}
- self.selectable = {}
for resource_name in self.google_ads_resource_names:
@@ -188,7 +199,6 @@ def extract_field_information(self, resource_schema):
self.behavior[field_name] = field["field_details"]["category"]
- self.selectable[field_name] = field["field_details"]["selectable"]
self.add_extra_fields(resource_schema)
self.field_exclusions = {k: list(v) for k, v in self.field_exclusions.items()}
@@ -327,7 +337,7 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
with Transformer() as transformer:
# Pages are fetched automatically while iterating through the response
for message in response:
- json_message = json.loads(MessageToJson(message, preserving_proto_field_name=True))
+ json_message = google_message_to_json(message)
transformed_obj = self.transform_keys(json_message)
record = transformer.transform(transformed_obj, stream["schema"], singer.metadata.to_map(stream_mdata))
@@ -369,13 +379,12 @@ def format_field_names(self):
"""
for resource_name, schema in self.full_schema["properties"].items():
for field_name, data_type in schema["properties"].items():
- # Ensure that attributed resource fields have the resource name as a prefix, eg campaign_id under the ad_groups stream
- if resource_name not in {"metrics", "segments"} and resource_name not in self.google_ads_resource_names:
- self.stream_schema["properties"][f"{resource_name}_{field_name}"] = data_type
# Move ad_group_ad.ad.x fields up a level in the schema (ad_group_ad.ad.x -> ad_group_ad.x)
- elif resource_name == "ad_group_ad" and field_name == "ad":
+ if resource_name == "ad_group_ad" and field_name == "ad":
for ad_field_name, ad_field_schema in data_type["properties"].items():
self.stream_schema["properties"][ad_field_name] = ad_field_schema
+ elif resource_name not in {"metrics", "segments"}:
+ self.stream_schema["properties"][f"{resource_name}_{field_name}"] = data_type
else:
self.stream_schema["properties"][field_name] = data_type
@@ -388,22 +397,22 @@ def build_stream_metadata(self):
"valid-replication-keys": ["date"]
},
("properties", "_sdc_record_hash"): {
- "inclusion": "automatic"
+ "inclusion": "automatic",
+ "behavior": "PRIMARY KEY"
},
}
for report_field in self.fields:
# Transform the field name to match the schema
is_metric_or_segment = report_field.startswith("metrics.") or report_field.startswith("segments.")
- if (not is_metric_or_segment
- and report_field.split(".")[0] not in self.google_ads_resource_names
- ):
- transformed_field_name = "_".join(report_field.split(".")[:2])
# Transform ad_group_ad.ad.x fields to just x to reflect ad_group_ads schema
- elif report_field.startswith("ad_group_ad.ad."):
+ if report_field.startswith("ad_group_ad.ad."):
transformed_field_name = report_field.split(".")[2]
+ elif not is_metric_or_segment:
+ transformed_field_name = "_".join(report_field.split(".")[:2])
else:
transformed_field_name = report_field.split(".")[1]
-
+ # TODO: Maybe refactor this
+ # metadata_key = ("properties", transformed_field_name)
# Base metadata for every field
if ("properties", transformed_field_name) not in self.stream_metadata:
self.stream_metadata[("properties", transformed_field_name)] = {
@@ -414,9 +423,7 @@ def build_stream_metadata(self):
# Transform field exclusion names so they match the schema
for field_name in self.field_exclusions[report_field]:
is_metric_or_segment = field_name.startswith("metrics.") or field_name.startswith("segments.")
- if (not is_metric_or_segment
- and field_name.split(".")[0] not in self.google_ads_resource_names
- ):
+ if not is_metric_or_segment:
new_field_name = field_name.replace(".", "_")
else:
new_field_name = field_name.split(".")[1]
@@ -442,13 +449,22 @@ def transform_keys(self, obj):
transformed_obj = {}
for resource_name, value in obj.items():
- if resource_name == "ad_group_ad":
- transformed_obj.update(value["ad"])
- else:
+ if resource_name in {"metrics", "segments"}:
transformed_obj.update(value)
-
- if "type_" in transformed_obj:
- transformed_obj["type"] = transformed_obj.pop("type_")
+ elif resource_name == "ad_group_ad":
+ for key, sub_value in value.items():
+ if key == 'ad':
+ transformed_obj.update(sub_value)
+ else:
+ transformed_obj.update({f"{resource_name}_{key}": sub_value})
+ else:
+ # value = {"a": 1, "b":2}
+ # turns into
+ # {"resource_a": 1, "resource_b": 2}
+ transformed_obj.update(
+ {f"{resource_name}_{key}": sub_value
+ for key, sub_value in value.items()}
+ )
return transformed_obj
@@ -507,7 +523,7 @@ def sync(self, sdk_client, customer, stream, config, state):
with Transformer() as transformer:
# Pages are fetched automatically while iterating through the response
for message in response:
- json_message = json.loads(MessageToJson(message, preserving_proto_field_name=True))
+ json_message = google_message_to_json(message)
transformed_obj = self.transform_keys(json_message)
record = transformer.transform(transformed_obj, stream["schema"])
record["_sdc_record_hash"] = generate_hash(record, stream_mdata)
diff --git a/tests/base.py b/tests/base.py
index 1c3412b..f42f6bb 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -618,7 +618,6 @@ def expected_default_fields():
},
"ad_group_audience_performance_report": {
'ad_group_name',
- 'user_list_name',
},
"campaign_performance_report": {
'average_cpc', # Avg. CPC,
@@ -631,24 +630,24 @@ def expected_default_fields():
'view_through_conversions', # View-through conv.,
},
"click_performance_report": {
- 'ad_group_ad',
+ 'click_view_ad_group_ad',
'ad_group_id',
'ad_group_name',
'ad_group_status',
'ad_network_type',
- 'area_of_interest',
- 'campaign_location_target',
+ 'click_view_area_of_interest',
+ 'click_view_campaign_location_target',
'click_type',
'clicks',
'customer_descriptive_name',
'customer_id',
'device',
- 'gclid',
- 'location_of_presence',
+ 'click_view_gclid',
+ 'click_view_location_of_presence',
'month_of_year',
- 'page_number',
+ 'click_view_page_number',
'slot',
- 'user_list',
+ 'click_view_user_list',
},
"display_keyword_performance_report": { # TODO NO DATA AVAILABLE
'ad_group_name',
@@ -702,13 +701,13 @@ def expected_default_fields():
'click_type',
'clicks',
'date',
- 'descriptive_name',
- 'id',
+ 'customer_descriptive_name',
+ 'customer_id',
'impressions',
'invalid_clicks',
- 'manager',
- 'test_account',
- 'time_zone',
+ 'customer_manager',
+ 'customer_test_account',
+ 'customer_time_zone',
},
"geo_performance_report": {
'clicks',
@@ -739,7 +738,7 @@ def expected_default_fields():
'conversions',
'view_through_conversions', # View-through conv.,
'cost_per_conversion', # Cost / conv.,
- 'search_term',
+ 'search_term_view_search_term',
'search_term_match_type',
},
"age_range_performance_report": {
@@ -761,7 +760,7 @@ def expected_default_fields():
'clicks',
'cost_micros',
'interactions',
- 'placeholder_type',
+ 'feed_placeholder_view_placeholder_type',
},
'user_location_performance_report': {
'campaign_id',
@@ -773,14 +772,14 @@ def expected_default_fields():
'campaign_name',
'clicks',
'average_cpc',
- 'unexpanded_final_url',
+ 'landing_page_view_unexpanded_final_url',
},
'expanded_landing_page_report': {
'ad_group_name',
'campaign_name',
'clicks',
'average_cpc',
- 'expanded_final_url',
+ 'expanded_landing_page_view_expanded_final_url',
},
'campaign_audience_performance_report': {
'campaign_name',
diff --git a/tests/base_google_ads_field_exclusion.py b/tests/base_google_ads_field_exclusion.py
new file mode 100644
index 0000000..b15298c
--- /dev/null
+++ b/tests/base_google_ads_field_exclusion.py
@@ -0,0 +1,138 @@
+"""Test tap field exclusions with random field selection."""
+from datetime import datetime as dt
+from datetime import timedelta
+import random
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class FieldExclusionGoogleAdsBase(GoogleAdsBase):
+ """
+ Test tap's field exclusion logic for all streams
+
+ NOTE: Manual test case must be run at least once any time this feature changes or is updated.
+ Verify when given field selected, `fieldExclusions` fields in metadata are grayed out and cannot be selected (Manually)
+ """
+
+ def choose_randomly(self, collection):
+ return random.choice(list(collection))
+
+ def random_field_gather(self, input_fields_with_exclusions):
+ """
+ Method takes list of fields with exclusions and generates a random set fields without conflicts as a result
+ The set of fields with exclusions is generated in random order so that different combinations of fields can
+ be tested over time.
+ """
+ random_selection = []
+
+ # Assemble a valid selection of fields with exclusions
+ remaining_fields = list(self.fields_with_exclusions)
+ while remaining_fields:
+
+ # Choose randomly from the remaining fields
+ field_to_select = self.choose_randomly(remaining_fields)
+ random_selection.append(field_to_select)
+
+ # Remove field and it's excluded fields from remaining
+ remaining_fields.remove(field_to_select)
+ for field in self.field_exclusions[field_to_select]:
+ if field in remaining_fields:
+ remaining_fields.remove(field)
+
+ # Save list for debug in case test fails
+ self.random_order_of_exclusion_fields[self.stream].append(field_to_select)
+
+ return random_selection
+
+ def run_test(self):
+ """
+ Verify tap can perform sync for random combinations of fields that do not violate exclusion rules.
+ Established randomization for valid field selection using new method to select specific fields.
+ """
+
+ print(
+ "Field Exclusion Test with random field selection for tap-google-ads report streams.\n"
+ f"Streams Under Test: {self.streams_to_test}"
+ )
+
+ self.random_order_of_exclusion_fields = {}
+
+ # bump start date from default
+ self.start_date = dt.strftime(dt.today() - timedelta(days=1), self.START_DATE_FORMAT)
+ conn_id = connections.ensure_connection(self, original_properties=False)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ for stream in self.streams_to_test:
+ with self.subTest(stream=stream):
+
+ catalogs_to_test = [catalog
+ for catalog in found_catalogs
+ if catalog["stream_name"] == stream]
+
+ # Make second call to get field level metadata
+ schema = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
+ self.field_exclusions = {
+ rec['breadcrumb'][1]: rec['metadata']['fieldExclusions']
+ for rec in schema['metadata']
+ if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash"
+ }
+
+ print(f"Perform assertions for stream: {stream}")
+
+ # Gather fields with no exclusions so they can all be added to selection set
+ self.fields_without_exclusions = []
+ for field, values in self.field_exclusions.items():
+ if values == []:
+ self.fields_without_exclusions.append(field)
+
+ # Gather fields with exclusions as input to randomly build maximum length selection set
+ self.fields_with_exclusions = []
+ for field, values in self.field_exclusions.items():
+ if values != []:
+ self.fields_with_exclusions.append(field)
+
+ if len(self.fields_with_exclusions) == 0:
+ raise AssertionError(f"Skipping assertions. No field exclusions for stream: {stream}")
+
+ self.stream = stream
+ self.random_order_of_exclusion_fields[stream] = []
+
+ random_exclusion_field_selection_list = self.random_field_gather(self.fields_with_exclusions)
+ field_selection_set = set(random_exclusion_field_selection_list + self.fields_without_exclusions)
+
+ with self.subTest(order_of_fields_selected=self.random_order_of_exclusion_fields[stream]):
+
+ # Select fields and re-pull annotated_schema.
+ self.select_stream_and_specified_fields(conn_id, catalogs_to_test[0], field_selection_set)
+
+ try:
+ # Collect updated metadata
+ schema_2 = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
+
+ # Verify metadata for all fields
+ for rec in schema_2['metadata']:
+ if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash":
+ # Verify metadata for selected fields
+ if rec['breadcrumb'][1] in field_selection_set:
+ self.assertEqual(rec['metadata']['selected'], True,
+ msg="Expected selection for field {} = 'True'".format(rec['breadcrumb'][1]))
+
+ else: # Verify metadata for non selected fields
+ self.assertEqual(rec['metadata']['selected'], False,
+ msg="Expected selection for field {} = 'False'".format(rec['breadcrumb'][1]))
+
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+ state = menagerie.get_state(conn_id)
+
+ self.assertIn(stream, state['bookmarks'].keys())
+
+ finally:
+ # deselect stream once it's been tested
+ self.deselect_streams(conn_id, catalogs_to_test)
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index d63d6be..8eb4231 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -285,7 +285,7 @@ def test_run(self):
expected_replication_method = self.expected_replication_method()[stream]
# expected_fields = self.expected_fields()[stream] # TODO_TDL-17909
is_report = self.is_report(stream)
- expected_behaviors = {'METRIC', 'SEGMENT', 'ATTRIBUTE'} if is_report else {'ATTRIBUTE', 'SEGMENT'}
+ expected_behaviors = {'METRIC', 'SEGMENT', 'ATTRIBUTE', 'PRIMARY KEY'} if is_report else {'ATTRIBUTE', 'SEGMENT'}
# collecting actual values from the catalog
schema_and_metadata = menagerie.get_annotated_schema(conn_id, catalog['stream_id'])
@@ -365,8 +365,6 @@ def test_run(self):
msg="Not all non key properties are set to available in metadata")
# verify 'behavior' is present in metadata for all streams
- if is_report:
- actual_fields.remove('_sdc_record_hash')
self.assertEqual(fields_with_behavior, set(actual_fields))
# verify 'behavior' falls into expected set of behaviors (based on stream type)
@@ -374,20 +372,21 @@ def test_run(self):
with self.subTest(field=field):
self.assertIn(behavior, expected_behaviors)
- # NB | The following assertion is left commented with the assumption that this will be a valid
- # expectation by the time the tap moves to Beta. If this is not valid at that time it should
- # be removed. Or if work done in TDL-17910 results in this being an unnecessary check
-
- # if is_report:
- # # verify each field in a report stream has a 'fieldExclusions' entry and that the fields listed
- # # in that set are present in elsewhere in the stream's catalog
- # fields_to_exclusions = {md['breadcrumb'][-1]: md['metadata']['fieldExclusions']
- # for md in metadata
- # if md['breadcrumb'] != [] and
- # md['metadata'].get('fieldExclusions')}
- # for field, exclusions in fields_to_exclusions.items():
- # with self.subTest(field=field):
- # self.assertTrue(
- # set(exclusions).issubset(set(actual_fields)),
- # msg=f"'fieldExclusions' contain fields not accounted for by the catalog: {set(exclusions) - set(actual_fields)}"
- # )
+ # TODO put back when field exlusion changes are merged
+ # verify for each report stream with exlusions, that all supported fields are mutually exlcusive
+ if is_report and stream != "click_performance_report":
+ fields_to_exclusions = {md['breadcrumb'][-1]: md['metadata']['fieldExclusions']
+ for md in metadata
+ if md['breadcrumb'] != [] and
+ md['metadata'].get('fieldExclusions')}
+ for field, exclusions in fields_to_exclusions.items():
+ for excluded_field in exclusions:
+ with self.subTest(field=field, excluded_field=excluded_field):
+
+ if excluded_field in actual_fields: # some fields in the exclusion list are not supported
+
+ # Verify the excluded field has it's own exclusion list
+ self.assertIsNotNone(fields_to_exclusions.get(excluded_field))
+
+ # Verify the excluded field is excluding the original field (mutual exclusion)
+ self.assertIn(field, fields_to_exclusions[excluded_field])
diff --git a/tests/test_google_ads_field_exclusion.py b/tests/test_google_ads_field_exclusion.py
deleted file mode 100644
index 596111a..0000000
--- a/tests/test_google_ads_field_exclusion.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""Test tap field exclusions with random field selection."""
-from datetime import datetime as dt
-from datetime import timedelta
-import random
-
-from tap_tester import menagerie, connections, runner
-
-from base import GoogleAdsBase
-
-
-class FieldExclusionGoogleAds(GoogleAdsBase):
- """
- Test tap's field exclusion logic for all streams
-
- NOTE: Manual test case must be run at least once any time this feature changes or is updated.
- Verify when given field selected, `fieldExclusions` fields in metadata are grayed out and cannot be selected (Manually)
- """
- @staticmethod
- def name():
- return "tt_google_ads_field_exclusion"
-
- def random_field_gather(self, input_fields_with_exclusions):
- """
- Method takes list of fields with exclusions and generates a random set fields without conflicts as a result
- The set of fields with exclusions is generated in random order so that different combinations of fields can
- be tested over time.
- """
-
- # Build random set of fields with exclusions. Select as many as possible
- randomly_selected_list_of_fields_with_exclusions = []
- remaining_available_fields_with_exclusions = input_fields_with_exclusions
- while len(remaining_available_fields_with_exclusions) > 0:
- # Randomly select one field that has exclusions
- newly_added_field = remaining_available_fields_with_exclusions[
- random.randrange(len(remaining_available_fields_with_exclusions))]
- # Save list for debug incase test fails
- self.random_order_of_exclusion_fields[self.stream].append(newly_added_field,)
- randomly_selected_list_of_fields_with_exclusions.append(newly_added_field)
- # Update remaining_available_fields_with_exclusinos based on random selection
- newly_excluded_fields_to_remove = self.field_exclusions[newly_added_field]
- # Remove newly selected field
- remaining_available_fields_with_exclusions.remove(newly_added_field)
- # Remove associated excluded fields
- for field in newly_excluded_fields_to_remove:
- if field in remaining_available_fields_with_exclusions:
- remaining_available_fields_with_exclusions.remove(field)
-
- exclusion_fields_to_select = randomly_selected_list_of_fields_with_exclusions
-
- return exclusion_fields_to_select
-
-
- def test_default_case(self):
- """
- Verify tap can perform sync for random combinations of fields that do not violate exclusion rules.
- Established randomization for valid field selection using new method to select specific fields.
- """
- print("Field Exclusion Test with random field selection for tap-google-ads report streams")
-
- # --- Test report streams --- #
-
- streams_to_test = {stream for stream in self.expected_streams()
- if self.is_report(stream)} - {'click_performance_report'} # No exclusions
-
- # streams_to_test = streams_to_test - {
- # # These streams missing from expected_default_fields() method TODO unblocked due to random? Test them now
- # # 'shopping_performance_report',
- # # 'keywords_performance_report',
- # # TODO These streams have no data to replicate and fail the last assertion
- # 'video_performance_report',
- # 'audience_performance_report',
- # 'placement_performance_report',
- # 'display_topics_performance_report',
- # 'display_keyword_performance_report',
- # }
- # streams_to_test = {'gender_performance_report', 'placeholder_report',}
- random_order_of_exclusion_fields = {}
-
- # bump start date from default
- self.start_date = dt.strftime(dt.today() - timedelta(days=3), self.START_DATE_FORMAT)
- conn_id = connections.ensure_connection(self, original_properties=False)
-
- # Run a discovery job
- found_catalogs = self.run_and_verify_check_mode(conn_id)
-
- for stream in streams_to_test:
- with self.subTest(stream=stream):
-
- catalogs_to_test = [catalog
- for catalog in found_catalogs
- if catalog["stream_name"] == stream]
-
- # Make second call to get field level metadata
- schema = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
- field_exclusions = {
- rec['breadcrumb'][1]: rec['metadata']['fieldExclusions']
- for rec in schema['metadata']
- if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash"
- }
-
- self.field_exclusions = field_exclusions # expose filed_exclusions globally so other methods can use it
-
- print(f"Perform assertions for stream: {stream}")
-
- # Gather fields with no exclusions so they can all be added to selection set
- fields_without_exclusions = []
- for field, values in field_exclusions.items():
- if values == []:
- fields_without_exclusions.append(field)
-
- # Gather fields with exclusions as input to randomly build maximum length selection set
- fields_with_exclusions = []
- for field, values in field_exclusions.items():
- if values != []:
- fields_with_exclusions.append(field)
-
- if len(fields_with_exclusions) == 0:
- raise AssertionError(f"Skipping assertions. No field exclusions for stream: {stream}")
-
- self.stream = stream
- random_order_of_exclusion_fields[stream] = []
- self.random_order_of_exclusion_fields = random_order_of_exclusion_fields
-
- random_exclusion_field_selection_list = self.random_field_gather(fields_with_exclusions)
- field_selection_set = set(random_exclusion_field_selection_list + fields_without_exclusions)
-
- with self.subTest(order_of_fields_selected=self.random_order_of_exclusion_fields[stream]):
-
- # Select fields and re-pull annotated_schema.
- self.select_stream_and_specified_fields(conn_id, catalogs_to_test[0], field_selection_set)
-
- try:
- # Collect updated metadata
- schema_2 = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
-
- # Verify metadata for all fields
- for rec in schema_2['metadata']:
- if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash":
- # Verify metadata for selected fields
- if rec['breadcrumb'][1] in field_selection_set:
- self.assertEqual(rec['metadata']['selected'], True,
- msg="Expected selection for field {} = 'True'".format(rec['breadcrumb'][1]))
-
- else: # Verify metadata for non selected fields
- self.assertEqual(rec['metadata']['selected'], False,
- msg="Expected selection for field {} = 'False'".format(rec['breadcrumb'][1]))
-
- # Run a sync
- sync_job_name = runner.run_sync_mode(self, conn_id)
- exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
- menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
-
- # These streams likely replicate records using the default field selection but may not produce any
- # records when selecting this many fields with exclusions.
- # streams_unlikely_to_replicate_records = {
- # 'ad_performance_report',
- # 'account_performance_report',
- # 'shopping_performance_report',
- # 'search_query_performance_report',
- # 'placeholder_feed_item_report',
- # 'placeholder_report',
- # 'keywords_performance_report',
- # 'keywordless_query_report',
- # 'geo_performance_report',
- # 'gender_performance_report', # Very rare
- # 'ad_group_audience_performance_report',
- # 'age_range_performance_report',
- # 'campaign_audience_performance_report',
- # 'user_location_performance_report',
- # 'ad_group_performance_report',
- # }
-
- # if stream not in streams_unlikely_to_replicate_records:
- # sync_record_count = runner.examine_target_output_file(
- # self, conn_id, self.expected_streams(), self.expected_primary_keys())
- # self.assertGreater(
- # sum(sync_record_count.values()), 0,
- # msg="failed to replicate any data: {}".format(sync_record_count)
- # )
- # print("total replicated row count: {}".format(sum(sync_record_count.values())))
-
- # TODO additional assertions?
-
- finally:
- # deselect stream once it's been tested
- self.deselect_streams(conn_id, catalogs_to_test)
diff --git a/tests/test_google_ads_field_exclusion_1.py b/tests/test_google_ads_field_exclusion_1.py
new file mode 100644
index 0000000..8f51148
--- /dev/null
+++ b/tests/test_google_ads_field_exclusion_1.py
@@ -0,0 +1,27 @@
+"""Test tap field exclusions with random field selection."""
+from datetime import datetime as dt
+from datetime import timedelta
+import random
+
+from tap_tester import menagerie, connections, runner
+
+from base_google_ads_field_exclusion import FieldExclusionGoogleAdsBase
+
+
+class FieldExclusion1(FieldExclusionGoogleAdsBase):
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_exclusion_1"
+
+ streams_to_test = {
+ "account_performance_report",
+ "ad_group_audience_performance_report",
+ "ad_group_performance_report",
+ "ad_performance_report",
+ "age_range_performance_report",
+ "campaign_audience_performance_report",
+ }
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/test_google_ads_field_exclusion_2.py b/tests/test_google_ads_field_exclusion_2.py
new file mode 100644
index 0000000..21b90df
--- /dev/null
+++ b/tests/test_google_ads_field_exclusion_2.py
@@ -0,0 +1,27 @@
+"""Test tap field exclusions with random field selection."""
+from datetime import datetime as dt
+from datetime import timedelta
+import random
+
+from tap_tester import menagerie, connections, runner
+
+from base_google_ads_field_exclusion import FieldExclusionGoogleAdsBase
+
+
+class FieldExclusion2(FieldExclusionGoogleAdsBase):
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_exclusion_2"
+
+ streams_to_test = {
+ "campaign_performance_report",
+ # "click_performance_report", # NO EXCLUSIONS, SKIPPED INTENTIONALLY
+ "display_keyword_performance_report",
+ "display_topics_performance_report",
+ "expanded_landing_page_report",
+ "gender_performance_report",
+ }
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/test_google_ads_field_exclusion_3.py b/tests/test_google_ads_field_exclusion_3.py
new file mode 100644
index 0000000..a426dcd
--- /dev/null
+++ b/tests/test_google_ads_field_exclusion_3.py
@@ -0,0 +1,27 @@
+"""Test tap field exclusions with random field selection."""
+from datetime import datetime as dt
+from datetime import timedelta
+import random
+
+from tap_tester import menagerie, connections, runner
+
+from base_google_ads_field_exclusion import FieldExclusionGoogleAdsBase
+
+
+class FieldExclusion3(FieldExclusionGoogleAdsBase):
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_exclusion_3"
+
+ streams_to_test = {
+ "geo_performance_report",
+ "keywordless_query_report",
+ "keywords_performance_report",
+ "landing_page_report",
+ "placeholder_feed_item_report",
+ "placeholder_report",
+ }
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/test_google_ads_field_exclusion_4.py b/tests/test_google_ads_field_exclusion_4.py
new file mode 100644
index 0000000..4eea8ab
--- /dev/null
+++ b/tests/test_google_ads_field_exclusion_4.py
@@ -0,0 +1,27 @@
+"""Test tap field exclusions with random field selection."""
+from datetime import datetime as dt
+from datetime import timedelta
+import random
+
+from tap_tester import menagerie, connections, runner
+
+from base_google_ads_field_exclusion import FieldExclusionGoogleAdsBase
+
+
+class FieldExclusion4(FieldExclusionGoogleAdsBase):
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_exclusion_4"
+
+ streams_to_test = {
+ "placement_performance_report",
+ "search_query_performance_report",
+ "shopping_performance_report",
+ "user_location_performance_report",
+ "user_location_performance_report",
+ "video_performance_report",
+ }
+
+ def test_run(self):
+ self.run_test()
diff --git a/tests/test_google_ads_field_exclusion_coverage.py b/tests/test_google_ads_field_exclusion_coverage.py
new file mode 100644
index 0000000..8acf39a
--- /dev/null
+++ b/tests/test_google_ads_field_exclusion_coverage.py
@@ -0,0 +1,36 @@
+from base_google_ads_field_exclusion import FieldExclusionGoogleAdsBase
+
+
+class FieldExlusionCoverage(FieldExclusionGoogleAdsBase):
+
+ checking_coverage = True
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_exclusion_coverage"
+
+ def test_run(self):
+ """
+ Ensure all report streams are covered for the field exlusion test cases.
+ The report streams are spread out across several test classes for parallelism. This extra
+ step is required as we hardcode the streams under test in each of the four classes.
+ """
+ report_streams = {stream for stream in self.expected_streams()
+ if self.is_report(stream)
+ and stream != "click_performance_report"}
+
+ from test_google_ads_field_exclusion_1 import FieldExclusion1
+ from test_google_ads_field_exclusion_2 import FieldExclusion2
+ from test_google_ads_field_exclusion_3 import FieldExclusion3
+ from test_google_ads_field_exclusion_4 import FieldExclusion4
+
+ f1 = FieldExclusion1()
+ f2 = FieldExclusion2()
+ f3 = FieldExclusion3()
+ f4 = FieldExclusion4()
+
+ streams_under_test = f1.streams_to_test | f2.streams_to_test | f3.streams_to_test | f4.streams_to_test
+
+ self.assertSetEqual(report_streams, streams_under_test)
+ print("ALL REPORT STREAMS UNDER TEST")
+ print(f"Streams: {streams_under_test}")
diff --git a/tests/test_google_ads_field_exclusion_invalid.py b/tests/test_google_ads_field_exclusion_invalid.py
index 3816935..ea2a165 100644
--- a/tests/test_google_ads_field_exclusion_invalid.py
+++ b/tests/test_google_ads_field_exclusion_invalid.py
@@ -21,78 +21,53 @@ class FieldExclusionInvalidGoogleAds(GoogleAdsBase):
def name():
return "tt_google_ads_field_exclusion_invalid"
- def perform_exclusion_verification(self, field_exclusion_dict):
- """
- Verify for a pair of fields that if field_1 is in field_2's exclusion list then field_2 must be in field_1's exclusion list
- """
- error_dict = {}
- for field, values in field_exclusion_dict.items():
- if values != []:
- for value in values:
- if value in field_exclusion_dict.keys():
- if field not in field_exclusion_dict[value]:
- if field not in error_dict.keys():
- error_dict[field] = [value]
- else:
- error_dict[field] += [value]
-
- return error_dict
-
- def random_field_gather(self, input_fields_with_exclusions):
+ def choose_randomly(self, collection):
+ return random.choice(list(collection))
+
+ def random_field_gather(self):
"""
Method takes list of fields with exclusions and generates a random set fields without conflicts as a result
The set of fields with exclusions is generated in random order so that different combinations of fields can
be tested over time. A single invalid field is then added to violate exclusion rules.
"""
- # Build random set of fields with exclusions. Select as many as possible
- all_fields = input_fields_with_exclusions + self.fields_without_exclusions
- randomly_selected_list_of_fields_with_exclusions = []
- remaining_available_fields_with_exclusions = input_fields_with_exclusions
- while len(remaining_available_fields_with_exclusions) > 0:
- # Randomly select one field that has exclusions
- newly_added_field = remaining_available_fields_with_exclusions[
- random.randrange(len(remaining_available_fields_with_exclusions))]
- # Save list for debug incase test fails
- self.random_order_of_exclusion_fields[self.stream].append(newly_added_field,)
- randomly_selected_list_of_fields_with_exclusions.append(newly_added_field)
- # Update remaining_available_fields_with_exclusinos based on random selection
- newly_excluded_fields_to_remove = self.field_exclusions[newly_added_field]
- # Remove newly selected field
- remaining_available_fields_with_exclusions.remove(newly_added_field)
- # Remove associated excluded fields
- for field in newly_excluded_fields_to_remove:
- if field in remaining_available_fields_with_exclusions:
- remaining_available_fields_with_exclusions.remove(field)
+ random_selection = []
+
+ # Assemble a valid selection of fields with exclusions
+ remaining_fields = list(self.fields_with_exclusions)
+ while remaining_fields:
+
+ # Choose randomly from the remaining fields
+ field_to_select = self.choose_randomly(remaining_fields)
+ random_selection.append(field_to_select)
+
+ # Remove field and it's excluded fields from remaining
+ remaining_fields.remove(field_to_select)
+ for field in self.field_exclusions[field_to_select]:
+ if field in remaining_fields:
+ remaining_fields.remove(field)
+
+ # Save list for debug incase test fails
+ self.random_order_of_exclusion_fields[self.stream].append(field_to_select)
# Now add one more exclusion field to make the selection invalid
- found_invalid_field = False
- while found_invalid_field == False:
- # Select a field from our list at random
- invalid_field_partner = randomly_selected_list_of_fields_with_exclusions[
- random.randrange(len(randomly_selected_list_of_fields_with_exclusions))]
- # Find all fields excluded by selected field
- invalid_field_pool = self.field_exclusions[invalid_field_partner]
- # Remove any fields not in metadata properties for this stream
- for field in reversed(invalid_field_pool):
- if field not in all_fields:
- invalid_field_pool.remove(field)
-
- # Make sure there is still one left to select, if not try again
- if len(invalid_field_pool) == 0:
- continue
-
- # Select field randomly and unset flag to terminate loop
- invalid_field = invalid_field_pool[random.randrange(len(invalid_field_pool))]
- found_invalid_field = True
-
- # Add the invalid field to the lists
+ while True:
+ # Choose randomly from the selected fields
+ random_field = self.choose_randomly(random_selection)
+
+ # Choose randomly from that field's supported excluded fields
+ excluded_fields = set(self.field_exclusions[random_field])
+ supported_excluded_fields = {field for field in excluded_fields
+ if field in self.fields_with_exclusions}
+ if supported_excluded_fields:
+ invalid_field = self.choose_randomly(supported_excluded_fields)
+ break
+
+ # Add this invalid field to the selection
+ random_selection.append(invalid_field)
self.random_order_of_exclusion_fields[self.stream].append(invalid_field,)
- randomly_selected_list_of_fields_with_exclusions.append(invalid_field)
- exclusion_fields_to_select = randomly_selected_list_of_fields_with_exclusions
-
- return exclusion_fields_to_select
+ return random_selection
def test_invalid_case(self):
@@ -110,14 +85,14 @@ def test_invalid_case(self):
streams_to_test = {stream for stream in self.expected_streams()
if self.is_report(stream)} - {'click_performance_report'} # No exclusions. TODO remove dynamically
- #streams_to_test = {'search_query_performance_report', 'placeholder_report',}
+ # streams_to_test = {'search_query_performance_report'} # , 'placeholder_report',}
random_order_of_exclusion_fields = {}
tap_exit_status_by_stream = {}
exclusion_errors = {}
# bump start date from default
- self.start_date = dt.strftime(dt.today() - timedelta(days=3), self.START_DATE_FORMAT)
+ self.start_date = dt.strftime(dt.today() - timedelta(days=1), self.START_DATE_FORMAT)
conn_id = connections.ensure_connection(self, original_properties=False)
# Run a discovery job
@@ -133,43 +108,36 @@ def test_invalid_case(self):
# Make second call to get field metadata
schema = menagerie.get_annotated_schema(conn_id, catalogs_to_test[0]['stream_id'])
- field_exclusions = {
+ self.field_exclusions = {
rec['breadcrumb'][1]: rec['metadata']['fieldExclusions']
for rec in schema['metadata']
if rec['breadcrumb'] != [] and rec['breadcrumb'][1] != "_sdc_record_hash"
}
- self.field_exclusions = field_exclusions
-
# Gather fields with no exclusions so they can all be added to selection set
- fields_without_exclusions = []
- for field, values in field_exclusions.items():
+ self.fields_without_exclusions = []
+ for field, values in self.field_exclusions.items():
if values == []:
- fields_without_exclusions.append(field)
- self.fields_without_exclusions = fields_without_exclusions
+ self.fields_without_exclusions.append(field)
# Gather fields with exclusions as input to randomly build maximum length selection set
- fields_with_exclusions = []
- for field, values in field_exclusions.items():
+ self.fields_with_exclusions = []
+ for field, values in self.field_exclusions.items():
if values != []:
- fields_with_exclusions.append(field)
- if len(fields_with_exclusions) == 0:
+ self.fields_with_exclusions.append(field)
+ if len(self.fields_with_exclusions) == 0:
raise AssertionError(f"Skipping assertions. No field exclusions for stream: {stream}")
# Add new key to existing dicts
random_order_of_exclusion_fields[stream] = []
- exclusion_errors[stream] = {}
# Expose variables globally
self.stream = stream
self.random_order_of_exclusion_fields = random_order_of_exclusion_fields
# Build random lists
- random_exclusion_field_selection_list = self.random_field_gather(fields_with_exclusions)
- field_selection_set = set(random_exclusion_field_selection_list + fields_without_exclusions)
-
- # Collect any errors if they occur
- exclusion_errors[stream] = self.perform_exclusion_verification(field_exclusions)
+ random_exclusion_field_selection_list = self.random_field_gather()
+ field_selection_set = set(random_exclusion_field_selection_list + self.fields_without_exclusions)
with self.subTest(order_of_fields_selected=self.random_order_of_exclusion_fields[stream]):
@@ -192,46 +160,34 @@ def test_invalid_case(self):
self.assertEqual(rec['metadata']['selected'], False,
msg="Expected selection for field {} = 'False'".format(rec['breadcrumb'][1]))
- # # Run a sync
- # sync_job_name = runner.run_sync_mode(self, conn_id)
- # exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
-
- # print(f"Perform assertions for stream: {stream}")
- # if exit_status.get('target_exit_status') == 1:
- # #print(f"Stream {stream} has tap_exit_status = {exit_status.get('tap_exit_status')}\n" +
- # # "Message: {exit_status.get('tap_error_message')")
- # tap_exit_status_by_stream[stream] = exit_status.get('tap_exit_status')
- # else:
- # #print(f"\n*** {stream} tap_exit_status {exit_status.get('tap_exit_status')} ***\n")
- # tap_exit_status_by_stream[stream] = exit_status.get('tap_exit_status')
- # #self.assertEqual(1, exit_status.get('tap_exit_status')) # 11 failures on run 1
- # self.assertEqual(0, exit_status.get('target_exit_status'))
- # self.assertEqual(0, exit_status.get('discovery_exit_status'))
- # self.assertIsNone(exit_status.get('check_exit_status'))
+ # Run a sync
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+
+ print(f"Perform assertions for stream: {stream}")
+ if exit_status.get('target_exit_status') == 1:
+ print(f"Stream {stream} has tap_exit_status = {exit_status.get('tap_exit_status')}\n" +
+ "Message: {exit_status.get('tap_error_message')")
+ tap_exit_status_by_stream[stream] = exit_status.get('tap_exit_status')
+ else:
+ print(f"\n*** {stream} tap_exit_status {exit_status.get('tap_exit_status')} ***\n")
+ tap_exit_status_by_stream[stream] = exit_status.get('tap_exit_status')
+ self.assertEqual(1, exit_status.get('tap_exit_status'))
+ self.assertEqual(0, exit_status.get('target_exit_status'))
+ self.assertEqual(0, exit_status.get('discovery_exit_status'))
+ self.assertIsNone(exit_status.get('check_exit_status'))
# Verify error message tells user they must select an attribute/metric for the invalid stream
- # TODO build list of strings to test in future
-
- # Initial assertion group generated if all fields selelcted
- # self.assertIn(
- # "PROHIBITED_FIELD_COMBINATION_IN_SELECT_CLAUSE",
- # exit_status.get("tap_error_message")
- # )
- # self.assertIn(
- # "The following pairs of fields may not be selected together",
- # exit_status.get("tap_error_message")
- # )
-
- # New error message if random selection method is used
- # PROHIBITED_SEGMENT_WITH_METRIC_IN_SELECT_OR_WHERE_CLAUSE
-
- # TODO additional assertions?
- # self.assertEqual(len(exclusion_erros[stream], 0)
-
+ error_messages = ["The following pairs of fields may not be selected together",
+ "Cannot select or filter on the following",
+ "Cannot select the following",]
+ self.assertTrue(
+ any([error_message in exit_status.get("tap_error_message")
+ for error_message in error_messages]),
+ msg=f'Unexpected Error Message: {exit_status.get("tap_error_message")}')
+ print(f"\n*** {stream} tap_error_message {exit_status.get('tap_error_message')} ***\n")
finally:
# deselect stream once it's been tested
self.deselect_streams(conn_id, catalogs_to_test)
print("Streams tested: {}\ntap_exit_status_by_stream: {}".format(len(streams_to_test), tap_exit_status_by_stream))
- print("Exclusion errors:")
- pprint.pprint(exclusion_errors)
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index 9618251..ee5bc7d 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -41,9 +41,11 @@ def test_run(self):
# Perform table and field selection...
core_catalogs = [catalog for catalog in test_catalogs
- if not self.is_report(catalog['stream_name'])]
+ if not self.is_report(catalog['stream_name'])
+ or catalog['stream_name'] == 'click_performance_report']
report_catalogs = [catalog for catalog in test_catalogs
- if self.is_report(catalog['stream_name'])]
+ if self.is_report(catalog['stream_name'])
+ and catalog['stream_name'] != 'click_performance_report']
# select all fields for core streams and...
self.select_all_streams_and_fields(conn_id, core_catalogs, select_all_fields=True)
# select 'default' fields for report streams
diff --git a/tests/unittests/test_utils.py b/tests/unittests/test_utils.py
index 6571e6d..dd55744 100644
--- a/tests/unittests/test_utils.py
+++ b/tests/unittests/test_utils.py
@@ -123,7 +123,7 @@ class TestRecordHashing(unittest.TestCase):
('properties', 'date'): {'behavior': 'SEGMENT'},
})
- expected_hash = 'ade8240f134633fe125388e469e61ccf9e69033fd5e5f166b4b44766bc6376d3'
+ expected_hash = '38d95857633f1e04092f7a308f0d3777d965cba80a5593803dd2b7e4a484ce64'
def test_record_hash_canary(self):
self.assertEqual(self.expected_hash, generate_hash(self.test_record, self.test_metadata))
From 1d7a736a34ac260cf820e900b1f226a8379a69dd Mon Sep 17 00:00:00 2001
From: Andy Lu
Date: Thu, 24 Mar 2022 13:59:38 -0400
Subject: [PATCH 34/69] Bump to v0.3.0, update changelog (#40)
Co-authored-by: Bryant Gray
---
CHANGELOG.md | 9 +++++++++
setup.py | 2 +-
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b184ef8..b8990e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## v0.3.0
+ * Removes unused code
+ * Adds a behavior to "_sdc_record_hash"
+ * Fixed field exclusion for segments and attributes
+ * Adds tests for field exclusion
+ * Updates the "type_" field to "type" Transform type_ to type [#36](https://github.com/singer-io/tap-google-ads/pull/36)
+ * Updates fields in the report streams to include the Google Ads resource name Report streams prefix resource names [#37](https://github.com/singer-io/tap-google-ads/pull/37)
+ * Updates the generate_hash function to be explicit about the order of the fields getting hashed Change _sdc_record_hash to sorted list of tuples [#38](https://github.com/singer-io/tap-google-ads/pull/38)
+
## v0.2.0 [#31](https://github.com/singer-io/tap-google-ads/pull/31)
* Add ability for the tap to use `currently_syncing` [#24](https://github.com/singer-io/tap-google-ads/pull/24)
* Add `end_date` as a configurable property to end a sync at a certain date [#28](https://github.com/singer-io/tap-google-ads/pull/28)
diff --git a/setup.py b/setup.py
index c80439e..9e290ee 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='0.2.0',
+ version='0.3.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From c7fc2ced4fcbe32872fe351946564a2e9f26d2da Mon Sep 17 00:00:00 2001
From: bhtowles
Date: Wed, 30 Mar 2022 10:26:04 -0500
Subject: [PATCH 35/69] Final review updates, TODOs (#41)
* Final review updates, TODOs
* Final review PR comments
Co-authored-by: btowles
---
tests/base.py | 98 +-------
tests/test_google_ads_bookmarks.py | 7 +-
tests/test_google_ads_conversion_window.py | 4 +-
...st_google_ads_conversion_window_invalid.py | 6 +-
tests/test_google_ads_discovery.py | 229 ------------------
...test_google_ads_field_exclusion_invalid.py | 3 +-
tests/test_google_ads_interrupted_sync.py | 7 +-
..._google_ads_interrupted_sync_add_stream.py | 3 +-
...terrupted_sync_remove_currently_syncing.py | 3 +-
...ogle_ads_interrupted_sync_remove_stream.py | 3 +-
tests/test_google_ads_start_date.py | 4 +-
11 files changed, 28 insertions(+), 339 deletions(-)
diff --git a/tests/base.py b/tests/base.py
index f42f6bb..d7d2f6c 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -51,14 +51,13 @@ def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
'start_date': '2021-12-01T00:00:00Z',
- 'user_id': 'not used?', # TODO ?
+ 'user_id': 'not used?', # Useless config property carried over from AdWords
'customer_ids': ','.join(self.get_customer_ids()),
# 'conversion_window_days': '30',
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
"loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
}
- # TODO_TDL-17911 Add a test around conversion_window_days
if original:
return return_value
@@ -72,10 +71,13 @@ def get_credentials(self):
'refresh_token': os.getenv('TAP_GOOGLE_ADS_REFRESH_TOKEN')}
def expected_metadata(self):
- """The expected streams and metadata about the streams"""
- # TODO Investigate the foreign key expectations here,
- # - must prove each uncommented entry is a true foregin key constraint.
- # - must prove each commented entry is a NOT true foregin key constraint.
+ """
+ The expected streams and metadata about the streams
+
+ DEPRECATED reports from tap-adwords:
+ "CRITERIA_PERFORMANCE_REPORT"
+ "FINAL_URL_REPORT" replaced by landing page / expanded landing page
+ """
return {
# Core Objects
"accounts": {
@@ -87,9 +89,6 @@ def expected_metadata(self):
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.FOREIGN_KEYS: {
- # 'accessible_bidding_strategy_id',
- # 'bidding_strategy_id',
- # 'campaign_budget_id',
'customer_id'
},
},
@@ -97,8 +96,6 @@ def expected_metadata(self):
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.FOREIGN_KEYS: {
- # 'accessible_bidding_strategy_id',
- # 'bidding_strategy_id',
'campaign_id',
'customer_id',
},
@@ -146,7 +143,7 @@ def expected_metadata(self):
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- # TODO Post Alpha
+ # TODO Post Beta
# "call_metrics_call_details_report": { # "call_view"
# self.PRIMARY_KEYS: {"_sdc_record_hash"},
# self.REPLICATION_METHOD: self.INCREMENTAL,
@@ -257,34 +254,14 @@ def expected_metadata(self):
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- # "criteria_performance_report": { # DEPRECATED TODO maybe possilbe?
- # self.PRIMARY_KEYS: {"TODO"},
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
- # },
- # "final_url_report": { # DEPRECATED Replaced with landing page / expanded landing page
- # self.PRIMARY_KEYS: {},
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
- # },
- # Custom Reports TODO feature
+
+ # Custom Reports TODO Post Beta feature
}
def expected_streams(self):
"""A set of expected stream names"""
return set(self.expected_metadata().keys())
- # TODO confirm whether or not these apply for
- # core objects ?
- # report objects ?
- # def child_streams(self):
- # """
- # Return a set of streams that are child streams
- # based on having foreign key metadata
- # """
- # return {stream for stream, metadata in self.expected_metadata().items()
- # if metadata.get(self.FOREIGN_KEYS)}
-
def expected_foreign_keys(self):
"""
return a dictionary with key of table name
@@ -389,58 +366,6 @@ def run_and_verify_sync(self, conn_id):
return sync_record_count
- # TODO we may need to account for exclusion rules
- def perform_and_verify_table_and_field_selection(self, conn_id, test_catalogs,
- select_default_fields: bool = True,
- select_pagination_fields: bool = False):
- """
- Perform table and field selection based off of the streams to select
- set and field selection parameters. Note that selecting all fields is not
- possible for this tap due to dimension/metric conflicts set by Google and
- enforced by the Stitch UI.
-
- Verify this results in the expected streams selected and all or no
- fields selected for those streams.
- """
-
- # Select all available fields or select no fields from all testable streams
- self.select_all_streams_and_fields(conn_id, test_catalogs, True)
- # self._select_streams_and_fields(
- # conn_id=conn_id, catalogs=test_catalogs,
- # select_default_fields=select_default_fields,
- # select_pagination_fields=select_pagination_fields
- # )
-
- catalogs = menagerie.get_catalogs(conn_id)
-
- # Ensure our selection affects the catalog
- expected_selected_streams = [tc.get('stream_name') for tc in test_catalogs]
- expected_default_fields = self.expected_default_fields()
- expected_pagination_fields = self.expected_pagination_fields()
- for cat in catalogs:
- catalog_entry = menagerie.get_annotated_schema(conn_id, cat['stream_id'])
-
- # Verify all intended streams are selected
- selected = catalog_entry['metadata'][0]['metadata'].get('selected')
- print("Validating selection on {}: {}".format(cat['stream_name'], selected))
- if cat['stream_name'] not in expected_selected_streams:
- self.assertFalse(selected, msg="Stream selected, but not testable.")
- continue # Skip remaining assertions if we aren't selecting this stream
- self.assertTrue(selected, msg="Stream not selected.")
-
- # collect field selection expecationas
- expected_automatic_fields = self.expected_automatic_fields()[cat['stream_name']]
- selected_default_fields = expected_default_fields[cat['stream_name']] if select_default_fields else set()
- selected_pagination_fields = expected_pagination_fields[cat['stream_name']] if select_pagination_fields else set()
-
- # Verify all intended fields within the stream are selected
- expected_selected_fields = expected_automatic_fields | selected_default_fields | selected_pagination_fields
- selected_fields = self._get_selected_fields_from_metadata(catalog_entry['metadata'])
- for field in expected_selected_fields:
- field_selected = field in selected_fields
- print("\tValidating field selection on {}.{}: {}".format(cat['stream_name'], field, field_selected))
- self.assertSetEqual(expected_selected_fields, selected_fields)
-
@staticmethod
def _get_selected_fields_from_metadata(metadata):
selected_fields = set()
@@ -583,7 +508,6 @@ def select_stream_and_specified_fields(self, conn_id, catalog, fields_to_select:
def is_report(self, stream):
return stream.endswith('_report')
- # TODO exclusion rules
@staticmethod
def expected_default_fields():
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index 23a7998..a6cf857 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -35,10 +35,9 @@ def test_run(self):
state < (today - converstion window), therefore the state should be used
on sync 2
- Outstanding Work:
- TODO_TDL-17918 Determine if we can test the following case at the tap-tester level
- A sync results in a state such that state > (today - converstion window), therfore the
- tap should pick up based on (today - converstion window) on sync 2.
+ Note:
+ TDL-17918 implemented tap-tester level conversion window testing. Unit tests cover
+ additional scenarios
"""
print("Bookmarks Test for tap-google-ads")
diff --git a/tests/test_google_ads_conversion_window.py b/tests/test_google_ads_conversion_window.py
index 88e83b1..080be26 100644
--- a/tests/test_google_ads_conversion_window.py
+++ b/tests/test_google_ads_conversion_window.py
@@ -32,7 +32,7 @@ def get_properties(self):
"""Configurable properties, with a switch to override the 'start_date' property"""
return {
'start_date': dt.strftime(dt.utcnow() - timedelta(days=91), self.START_DATE_FORMAT),
- 'user_id': 'not used?', # TODO ?
+ 'user_id': 'not used?',
'customer_ids': ','.join(self.get_customer_ids()),
'conversion_window': self.conversion_window,
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
@@ -42,7 +42,7 @@ def get_properties(self):
def run_test(self):
"""
Testing that basic sync functions without Critical Errors when
- a valid conversion_windown is set.
+ a valid conversion_window is set.
"""
print("Configurable Properties Test (conversion_window)")
diff --git a/tests/test_google_ads_conversion_window_invalid.py b/tests/test_google_ads_conversion_window_invalid.py
index 8d4975f..e66edc8 100644
--- a/tests/test_google_ads_conversion_window_invalid.py
+++ b/tests/test_google_ads_conversion_window_invalid.py
@@ -33,7 +33,7 @@ def get_properties(self):
"""Configurable properties, with a switch to override the 'start_date' property"""
return {
'start_date': dt.strftime(dt.utcnow() - timedelta(days=91), self.START_DATE_FORMAT),
- 'user_id': 'not used?', # TODO ?
+ 'user_id': 'not used?',
'customer_ids': ','.join(self.get_customer_ids()),
'conversion_window': self.conversion_window,
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
@@ -43,7 +43,7 @@ def get_properties(self):
def run_test(self):
"""
Testing that basic sync functions without Critical Errors when
- a valid conversion_windown is set.
+ a valid conversion_window is set.
"""
print("Configurable Properties Test (conversion_window)")
@@ -105,7 +105,7 @@ def run_test(self):
err_msg_2 = "'bad_properties': ['conversion_window']"
print("Expected exception occurred.")
-
+
# Verify connection cannot be made with invalid conversion_window
print(f"Validating error message contains {err_msg_1}")
self.assertIn(err_msg_1, ex.args[0])
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index 8eb4231..13f1e68 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -9,230 +9,6 @@
class DiscoveryTest(GoogleAdsBase):
"""Test tap discovery mode and metadata conforms to standards."""
- def expected_fields(self):
- """
- The expected streams and metadata about the streams.
-
- TODO's in this method will be picked up as part of TDL-17909
- """
- return {
- # Core Objects
- "accounts": { # TODO_TDL-17909 check with Brian on changes
- # OLD FIELDS (with mapping)
- "currency_code",
- "id", # "customer_id",
- "manager", # "can_manage_clients",
- "resource_name", # name -- unclear if this actually the mapping
- "test_account",
- "time_zone", #"date_time_zone",
- # NEW FIELDS
- 'auto_tagging_enabled',
- 'call_reporting_setting.call_conversion_action',
- 'call_reporting_setting.call_conversion_reporting_enabled',
- 'call_reporting_setting.call_reporting_enabled',
- 'conversion_tracking_setting.conversion_tracking_id',
- 'conversion_tracking_setting.cross_account_conversion_tracking_id',
- 'descriptive_name',
- 'final_url_suffix',
- 'has_partners_badge',
- 'optimization_score',
- 'optimization_score_weight',
- 'pay_per_conversion_eligibility_failure_reasons',
- 'remarketing_setting.google_global_site_tag',
- 'tracking_url_template',
- },
- "campaigns": { # TODO_TDL-17909 check out nested keys once these are satisfied
- # OLD FIELDS
- "ad_serving_optimization_status",
- "advertising_channel_type",
- "base_campaign", # Was "base_campaign_id",
- "campaign_budget_id", # Was "budget_id",
- "end_date",
- "experiment_type", # Was campaign_trial_type",
- "frequency_caps", # Was frequency_cap",
- "id",
- "labels",
- "name",
- "network_settings.target_content_network", # Was network_setting
- "network_settings.target_google_search", # Was network_setting
- "network_settings.target_partner_search_network", # Was network_setting
- "network_settings.target_search_network", # Was network_setting
- "serving_status",
- "start_date",
- "status",
- "url_custom_parameters",
- #"conversion_optimizer_eligibility", # No longer present
- #"settings", # No clear mapping to replacement
- # NEW FIELDS
- "accessible_bidding_strategy",
- "accessible_bidding_strategy_id",
- "advertising_channel_sub_type",
- "app_campaign_setting.app_id",
- "app_campaign_setting.app_store",
- "app_campaign_setting.bidding_strategy_goal_type",
- "bidding_strategy",
- "bidding_strategy_id",
- "bidding_strategy_type",
- "campaign_budget",
- "commission.commission_rate_micros",
- "customer_id",
- "dynamic_search_ads_setting.domain_name",
- "dynamic_search_ads_setting.feeds",
- "dynamic_search_ads_setting.language_code",
- "dynamic_search_ads_setting.use_supplied_urls_only",
- "excluded_parent_asset_field_types",
- "final_url_suffix",
- "geo_target_type_setting.negative_geo_target_type",
- "geo_target_type_setting.positive_geo_target_type",
- "hotel_setting.hotel_center_id",
- "local_campaign_setting.location_source_type",
- "manual_cpc.enhanced_cpc_enabled",
- "manual_cpm",
- "manual_cpv",
- "maximize_conversion_value.target_roas",
- "maximize_conversions.target_cpa",
- "optimization_goal_setting.optimization_goal_types",
- "optimization_score",
- "payment_mode",
- "percent_cpc.cpc_bid_ceiling_micros",
- "percent_cpc.enhanced_cpc_enabled",
- "real_time_bidding_setting.opt_in",
- "resource_name",
- "selective_optimization.conversion_actions",
- "shopping_setting.campaign_priority",
- "shopping_setting.campaign_priority",
- "shopping_setting.enable_local",
- "shopping_setting.merchant_id",
- "shopping_setting.sales_country",
- "target_cpa.cpc_bid_ceiling_micros",
- "target_cpa.cpc_bid_floor_micros",
- "target_cpa.target_cpa_micros",
- "target_cpm",
- "target_impression_share.cpc_bid_ceiling_micros",
- "target_impression_share.location",
- "target_impression_share.location_fraction_micros",
- "target_roas.cpc_bid_ceiling_micros",
- "target_roas.cpc_bid_floor_micros",
- "target_roas.target_roas",
- "target_spend.cpc_bid_ceiling_micros",
- "target_spend.target_spend_micros",
- "targeting_setting.target_restrictions",
- "tracking_setting.tracking_url",
- "tracking_url_template",
- "url_expansion_opt_out",
- "vanity_pharma.vanity_pharma_display_url_mode",
- "vanity_pharma.vanity_pharma_text",
- "video_brand_safety_suitability",
- },
- "ad_groups": { # TODO_TDL-17909 check out nested keys once these are satisfied
- # OLD FIELDS (with mappings)
- "type", # ("ad_group_type")
- "base_ad_group", # ("base_ad_group_id")
- # "bidding_strategy_configuration", # DNE
- "campaign", #("campaign_name", "campaign_id", "base_campaign_id") # TODO_TDL-17909 redo this
- "id",
- "labels",
- "name",
- # "settings", # DNE
- "status",
- "url_custom_parameters",
- # NEW FIELDS
- 'resource_name',
- "tracking_url_template",
- "cpv_bid_micros",
- "campaign_id",
- "effective_target_cpa_micros",
- "display_custom_bid_dimension",
- "bidding_strategy_id",
- "target_cpm_micros",
- "explorer_auto_optimizer_setting.opt_in",
- "effective_target_cpa_source",
- "accessible_bidding_strategy_id",
- "excluded_parent_asset_field_types",
- "final_url_suffix",
- "percent_cpc_bid_micros",
- "effective_target_roas_source",
- "ad_rotation_mode",
- "targeting_setting.target_restrictions",
- "cpm_bid_micros",
- "customer_id",
- "cpc_bid_micros",
- "target_roas",
- "target_cpa_micros",
- "effective_target_roas",
- },
- "ads": { # TODO_TDL-17909 check out nested keys once these are satisfied
- # OLD FIELDS (with mappings)
- "ad_group_id",
- "base_ad_group_id",
- "base_campaign_id",
- 'policy_summary.policy_topic_entries', # ("policy_summary")
- 'policy_summary.review_status', # ("policy_summary")
- 'policy_summary.approval_status', # ("policy_summary")
- "status",
- # "trademark_disapproved", # DNE
- # NEW FIELDS
- },
- 'campaign_budgets': {
- "budget_id",
- },
- 'bidding_strategies': {
- "bids", # comparablevalue.type, microamount,
- "bid_source",
- "bids.type",
- },
- 'accessible_bidding_strategies': {
- "bids", # comparablevalue.type, microamount,
- "bid_source",
- "bids.type",
- },
- # Report objects
- "age_range_performance_report": { # "age_range_view"
- },
- "ad_group_audience_performance_report": { # "ad_group_audience_view"
- },
- "campaign_performance_report": { # "campaign"
- },
- "call_metrics_call_details_report": { # "call_view"
- },
- "click_performance_report": { # "click_view"
- },
- "display_keyword_performance_report": { # "display_keyword_view"
- },
- "display_topics_performance_report": { # "topic_view"
- },# TODO_TDL-17909 consult https://developers.google.com/google-ads/api/docs/migration/url-reports for migrating this report
- "gender_performance_report": { # "gender_view"
- },
- "geo_performance_report": { # "geographic_view", "user_location_view"
- },
- "keywordless_query_report": { # "dynamic_search_ads_search_term_view"
- },
- "keywords_performance_report": { # "keyword_view"
- },
- "landing_page_view": { # was final_url_report
- },
- "expanded_landing_page_view": { # was final_url_report
- },
- "placeholder_feed_item_report": { # "feed_item", "feed_item_target"
- },
- "placeholder_report": { # "feed_placeholder_view"
- },
- "placement_performance_report": { # "managed_placement_view"
- },
- "search_query_performance_report": { # "search_term_view"
- },
- "shopping_performance_report": { # "shopping_performance_view"
- },
- "video_performance_report": { # "video"
- },
- "account_performance_report": { # accounts
- },
- "ad_group_performance_report": { # ad_group
- },
- "ad_performance_report": { # ads
- },
- # Custom Reports [OUTSIDE SCOPE OF ALPHA]
- }
@staticmethod
def name():
@@ -283,7 +59,6 @@ def test_run(self):
expected_replication_keys = self.expected_replication_keys()[stream]
expected_automatic_fields = expected_primary_keys | expected_replication_keys | expected_foreign_keys
expected_replication_method = self.expected_replication_method()[stream]
- # expected_fields = self.expected_fields()[stream] # TODO_TDL-17909
is_report = self.is_report(stream)
expected_behaviors = {'METRIC', 'SEGMENT', 'ATTRIBUTE', 'PRIMARY KEY'} if is_report else {'ATTRIBUTE', 'SEGMENT'}
@@ -346,9 +121,6 @@ def test_run(self):
# verify replication key(s)
self.assertSetEqual(expected_replication_keys, actual_replication_keys)
- # verify all expected fields are found # TODO_TDL-17909 set expectations
- # self.assertSetEqual(expected_fields, set(actual_fields))
-
# verify the stream is given the inclusion of available
self.assertEqual(catalog['metadata']['inclusion'], 'available', msg=f"{stream} cannot be selected")
@@ -372,7 +144,6 @@ def test_run(self):
with self.subTest(field=field):
self.assertIn(behavior, expected_behaviors)
- # TODO put back when field exlusion changes are merged
# verify for each report stream with exlusions, that all supported fields are mutually exlcusive
if is_report and stream != "click_performance_report":
fields_to_exclusions = {md['breadcrumb'][-1]: md['metadata']['fieldExclusions']
diff --git a/tests/test_google_ads_field_exclusion_invalid.py b/tests/test_google_ads_field_exclusion_invalid.py
index ea2a165..2dd8280 100644
--- a/tests/test_google_ads_field_exclusion_invalid.py
+++ b/tests/test_google_ads_field_exclusion_invalid.py
@@ -83,7 +83,7 @@ def test_invalid_case(self):
# --- Test report streams --- #
streams_to_test = {stream for stream in self.expected_streams()
- if self.is_report(stream)} - {'click_performance_report'} # No exclusions. TODO remove dynamically
+ if self.is_report(stream)} - {'click_performance_report'} # No exclusions, skipped intentionally
# streams_to_test = {'search_query_performance_report'} # , 'placeholder_report',}
@@ -101,7 +101,6 @@ def test_invalid_case(self):
for stream in streams_to_test:
with self.subTest(stream=stream):
- # TODO Spike on running more than one sync per stream to increase the number of invalid field combos tested (Rushi)
catalogs_to_test = [catalog
for catalog in found_catalogs
if catalog["stream_name"] == stream]
diff --git a/tests/test_google_ads_interrupted_sync.py b/tests/test_google_ads_interrupted_sync.py
index d3e8137..34a88cf 100644
--- a/tests/test_google_ads_interrupted_sync.py
+++ b/tests/test_google_ads_interrupted_sync.py
@@ -18,13 +18,12 @@ def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
'start_date': '2022-01-22T00:00:00Z',
- 'user_id': 'not used?', # TODO ?
+ 'user_id': 'not used?',
'customer_ids': ','.join(self.get_customer_ids()),
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
"loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
}
- # TODO_TDL-17911 Add a test around conversion_window_days
if original:
return return_value
@@ -48,7 +47,7 @@ def test_run(self):
- Verify only records with replication-key values greater than or equal to the stream level bookmark are
replicated on the resuming sync for the interrupted stream.
- Verify the yet-to-be-synced streams are replicated following the interrupted stream in the resuming sync.
- (All yet-to-be-synced streams must replicate before streams that were already synced. - covered by unittests) TODO verify with devs
+ (All yet-to-be-synced streams must replicate before streams that were already synced. - covered by unittests)
NOTE: The following streams all had records for the dates used in this test. If needed they can be used in
testing cases like this in the future.
@@ -154,7 +153,7 @@ def test_run(self):
# gather expectations
expected_primary_key = list(self.expected_primary_keys()[stream])[0]
expected_replication_key = list(self.expected_replication_keys()[stream])[0] # assumes 1 value
- testable_customer_ids = set(self.get_customer_ids()) - {'2728292456'} # TODO before finalizing all tests make a standard for ref these
+ testable_customer_ids = set(self.get_customer_ids()) - {'2728292456'}
for customer in testable_customer_ids:
with self.subTest(customer_id=customer):
diff --git a/tests/test_google_ads_interrupted_sync_add_stream.py b/tests/test_google_ads_interrupted_sync_add_stream.py
index edb2c8c..c5d667c 100644
--- a/tests/test_google_ads_interrupted_sync_add_stream.py
+++ b/tests/test_google_ads_interrupted_sync_add_stream.py
@@ -18,14 +18,13 @@ def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
'start_date': '2022-01-22T00:00:00Z',
- 'user_id': 'not used?', # TODO ?
+ 'user_id': 'not used?',
'customer_ids': ','.join(self.get_customer_ids()),
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
"loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
}
- # TODO_TDL-17911 Add a test around conversion_window_days
if original:
return return_value
diff --git a/tests/test_google_ads_interrupted_sync_remove_currently_syncing.py b/tests/test_google_ads_interrupted_sync_remove_currently_syncing.py
index bea583d..306d928 100644
--- a/tests/test_google_ads_interrupted_sync_remove_currently_syncing.py
+++ b/tests/test_google_ads_interrupted_sync_remove_currently_syncing.py
@@ -18,13 +18,12 @@ def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
'start_date': '2022-01-22T00:00:00Z',
- 'user_id': 'not used?', # TODO ?
+ 'user_id': 'not used?',
'customer_ids': ','.join(self.get_customer_ids()),
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
"loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
}
- # TODO_TDL-17911 Add a test around conversion_window_days
if original:
return return_value
diff --git a/tests/test_google_ads_interrupted_sync_remove_stream.py b/tests/test_google_ads_interrupted_sync_remove_stream.py
index b3d893d..ae92667 100644
--- a/tests/test_google_ads_interrupted_sync_remove_stream.py
+++ b/tests/test_google_ads_interrupted_sync_remove_stream.py
@@ -18,13 +18,12 @@ def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
'start_date': '2022-01-22T00:00:00Z',
- 'user_id': 'not used?', # TODO ?
+ 'user_id': 'not used?',
'customer_ids': ','.join(self.get_customer_ids()),
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
"loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
}
- # TODO_TDL-17911 Add a test around conversion_window_days
if original:
return return_value
diff --git a/tests/test_google_ads_start_date.py b/tests/test_google_ads_start_date.py
index 64b3762..ce031bb 100644
--- a/tests/test_google_ads_start_date.py
+++ b/tests/test_google_ads_start_date.py
@@ -127,7 +127,7 @@ def run_test(self):
"Record date: {} ".format(replication_date)
)
- # TODO Remove if this does not apply with the lookback window at the time that it is
+ # TODO Remove if this does not apply with the conversion_window at the time that it is
# available as a configurable property.
# Verify the number of records replicated in sync 1 is greater than the number
# of records replicated in sync 2
@@ -163,7 +163,7 @@ def setUp(self):
self.start_date_2 = self.timedelta_formatted(self.start_date_1, days=15)
self.streams_to_test = self.expected_streams() - {
'search_query_performance_report', # Covered in other start date test
- } - self.missing_coverage_streams # TODO
+ } - self.missing_coverage_streams # TODO_TDL-17885
@staticmethod
def name():
From 15cb8224e28d525e0ea64528f0f5d300ee6e9c8c Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Wed, 30 Mar 2022 15:55:47 -0400
Subject: [PATCH 36/69] Add more tests around report PK hashing (#42)
Co-authored-by: Andy Lu
---
tests/unittests/test_utils.py | 72 ++++++++++++++++++++++++++++++++++-
1 file changed, 70 insertions(+), 2 deletions(-)
diff --git a/tests/unittests/test_utils.py b/tests/unittests/test_utils.py
index dd55744..c15cef2 100644
--- a/tests/unittests/test_utils.py
+++ b/tests/unittests/test_utils.py
@@ -96,6 +96,55 @@ class TestRecordHashing(unittest.TestCase):
'invalid_clicks': 0,
'date': '2022-01-19',
}
+
+ test_record_new_date = {
+ 'id': 1234567890,
+ 'currency_code': 'USD',
+ 'time_zone': 'America/New_York',
+ 'auto_tagging_enabled': False,
+ 'manager': False,
+ 'test_account': False,
+ 'impressions': 0,
+ 'interactions': 0,
+ 'invalid_clicks': 0,
+ 'date': '2022-01-20',
+ }
+
+ test_record_euro = {
+ 'id': 1234567890,
+ 'currency_code': 'EUR',
+ 'time_zone': 'Europe/Paris',
+ 'auto_tagging_enabled': False,
+ 'manager': False,
+ 'test_account': False,
+ 'impressions': 0,
+ 'interactions': 0,
+ 'invalid_clicks': 0,
+ 'date': '2022-01-19',
+ }
+
+ test_record_with_non_zero_metrics = {
+ 'id': 1234567890,
+ 'currency_code': 'USD',
+ 'time_zone': 'America/New_York',
+ 'auto_tagging_enabled': False,
+ 'manager': False,
+ 'test_account': False,
+ 'impressions': 10,
+ 'interactions': 10,
+ 'invalid_clicks': 10,
+ 'date': '2022-01-19',
+ }
+
+ test_record_without_metrics = {
+ 'id': 1234567890,
+ 'currency_code': 'USD',
+ 'time_zone': 'America/New_York',
+ 'auto_tagging_enabled': False,
+ 'manager': False,
+ 'test_account': False,
+ 'date': '2022-01-19',
+ }
test_record_shuffled = {
'currency_code': 'USD',
@@ -142,8 +191,27 @@ def test_record_hash_is_different_with_non_metric_value(self):
test_diff_record = dict(self.test_record)
test_diff_record['date'] = '2022-02-03'
self.assertNotEqual(self.expected_hash, generate_hash(test_diff_record, self.test_metadata))
-
-
+
+ def test_record_hash_is_same_with_metrics_selected_and_no_metrics_selected(self):
+ self.assertEqual(self.expected_hash, generate_hash(self.test_record_without_metrics, self.test_metadata))
+ self.assertEqual(self.expected_hash, generate_hash(self.test_record, self.test_metadata))
+
+ def test_record_hash_is_same_with_metric_value_change(self):
+ hash_non_zero_metrics = generate_hash(self.test_record_with_non_zero_metrics, self.test_metadata)
+ hash_zero_metrics = generate_hash(self.test_record, self.test_metadata)
+ self.assertEqual(hash_zero_metrics, hash_non_zero_metrics)
+
+ def test_record_hash_is_different_with_different_segment_value(self):
+ hash_record_orignal_date = generate_hash(self.test_record, self.test_metadata)
+ hash_record_next_day = generate_hash(self.test_record_new_date, self.test_metadata)
+ self.assertNotEqual(hash_record_orignal_date, hash_record_next_day)
+
+ def test_record_hash_is_different_with_different_attribute_value(self):
+ hash_record_usd = generate_hash(self.test_record, self.test_metadata)
+ hash_record_euro = generate_hash(self.test_record_euro, self.test_metadata)
+ self.assertNotEqual(hash_record_usd, hash_record_euro)
+
+
class TestGetQueryDate(unittest.TestCase):
def test_one(self):
"""Given:
From 7afa99a2ef38654f123cbfa16ad38c80f2cc75ec Mon Sep 17 00:00:00 2001
From: bhtowles
Date: Fri, 1 Apr 2022 09:23:37 -0500
Subject: [PATCH 37/69] Add date ranges for report data (#43)
* Add date ranges for report data
* Review comments
Co-authored-by: btowles
---
.github/pull_request_template.md | 4 +--
tests/test_google_ads_sync_canary.py | 51 ++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 2 deletions(-)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index c71d3b3..9f2ad96 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -3,8 +3,8 @@
# QA steps
- [ ] automated tests passing
- - [ ] manual qa steps passing (list below)
-
+ - [ ] manual qa steps passing (See: tap-tester/reference/manual_tests)
+
# Risks
# Rollback steps
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index ee5bc7d..558f779 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -18,6 +18,57 @@ def name():
def test_run(self):
"""
Testing that basic sync functions without Critical Errors
+
+ Test Data available for the following report streams across the following dates (only the
+ first and last date that data was generated is listed).
+
+ $ jq 'select(.table_name | contains("report")) | .table_name,.messages[0].data.date,
+ .messages[-1].data.date' /tmp/tap-tester-target-out.json
+ "account_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "ad_group_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "ad_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "age_range_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "campaign_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "click_performance_report"
+ "2021-12-30T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "expanded_landing_page_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "gender_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "geo_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "keywordless_query_report"
+ "2022-01-20T00:00:00.000000Z"
+ "2022-01-25T00:00:00.000000Z"
+ "landing_page_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
+ "placeholder_feed_item_report"
+ "2021-12-07T00:00:00.000000Z"
+ "2021-12-31T00:00:00.000000Z"
+ "placeholder_report"
+ "2021-12-07T00:00:00.000000Z"
+ "2021-12-31T00:00:00.000000Z"
+ "search_query_performance_report"
+ "2022-01-20T00:00:00.000000Z"
+ "2022-01-25T00:00:00.000000Z"
+ "user_location_performance_report"
+ "2021-12-06T00:00:00.000000Z"
+ "2022-03-14T00:00:00.000000Z"
"""
print("Canary Sync Test for tap-google-ads")
From 009bfc4358cff47dc66c2ead4919d188b66123f6 Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Mon, 4 Apr 2022 10:39:50 -0400
Subject: [PATCH 38/69] Add keyword fields to `click_performace_report` (#44)
---
tap_google_ads/report_definitions.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index 7f5f803..31c4f97 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -754,6 +754,9 @@
"click_view.area_of_interest.region",
"click_view.campaign_location_target",
"click_view.gclid",
+ "click_view.keyword",
+ "click_view.keyword_info.match_type",
+ "click_view.keyword_info.text",
"click_view.location_of_presence.city",
"click_view.location_of_presence.country",
"click_view.location_of_presence.metro",
From a583d866651ba7ac6466e1f4de3ef28b6aaa6b8c Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Mon, 4 Apr 2022 13:49:28 -0400
Subject: [PATCH 39/69] Bump to v1.0.0, update changelog (#45)
---
CHANGELOG.md | 6 ++++++
setup.py | 2 +-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8990e2..7b8fa42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## v1.0.0
+ * Version bump for GA release
+ * Adds fields to click_view report definition [#44](https://github.com/singer-io/tap-google-ads/pull/44)
+ * Adds date ranges to tests for faster test runs [#43](https://github.com/singer-io/tap-google-ads/pull/43)
+ * Adds more tests around primary key hashing [#42](https://github.com/singer-io/tap-google-ads/pull/42)
+
## v0.3.0
* Removes unused code
* Adds a behavior to "_sdc_record_hash"
diff --git a/setup.py b/setup.py
index 9e290ee..416a5c9 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='0.3.0',
+ version='1.0.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From a1d3730e04992c12a76d2c68c7b99d0aeee16580 Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Fri, 8 Apr 2022 15:46:52 -0400
Subject: [PATCH 40/69] Qa/future testing (#48)
* documented reasons for untested streams
* list high level scenarios for manual qa checks
* remove stitch specific pr template
Co-authored-by: kspeer
---
.github/pull_request_template.md | 2 +-
tests/test_google_ads_sync_canary.py | 20 +++++++++++---------
2 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 9f2ad96..858a40e 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -3,7 +3,7 @@
# QA steps
- [ ] automated tests passing
- - [ ] manual qa steps passing (See: tap-tester/reference/manual_tests)
+ - [ ] manual qa steps passing
# Risks
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index 558f779..ca0765e 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -75,15 +75,17 @@ def test_run(self):
conn_id = connections.ensure_connection(self)
streams_to_test = self.expected_streams() - {
- # TODO_TDL-17885 the following are not yet implemented
- 'display_keyword_performance_report', # no test data available
- 'display_topics_performance_report', # no test data available
- 'placement_performance_report', # no test data available
- "keywords_performance_report", # no test data available
- "video_performance_report", # no test data available
- "shopping_performance_report", # no test data available (need Shopping campaign type)
- 'campaign_audience_performance_report', # no test data available
- 'ad_group_audience_performance_report', # Potential BUG see above
+ # no test data available, but can generate
+ 'display_keyword_performance_report', # Singer Display #2, Ad Group 2
+ 'display_topics_performance_report', # Singer Display #2, Ad Group 2
+ "keywords_performance_report", # needs a Search Campaign (currently have none)
+ # audiences are unclear on how metrics fall into segments
+ 'campaign_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ 'ad_group_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ # cannot generate test data
+ 'placement_performance_report', # need an app to run javascript to trace conversions
+ "video_performance_report", # need a video to show
+ "shopping_performance_report", # need Shopping campaign type, and link to a store
}
# Run a discovery job
From 06c7f3e1e807abd69c43b7dca6c5eab32969733f Mon Sep 17 00:00:00 2001
From: bryantgray
Date: Fri, 22 Apr 2022 12:10:37 -0400
Subject: [PATCH 41/69] Add call_details stream (#49)
* Add call_view core stream, filter non-attribute fields from core streams
* Change `call_view` stream name to `call_details`
* Make pylint happy
* Fix integration tests
* Remove campaign_id from foreign keys, split call_details foreign keys
* Update tests to exclude call_details as needed
* Add context around excluding call_details from tests
* Remove outdated TODO related to addition of call_details stream.
Co-authored-by: dsprayberry <28106103+dsprayberry@users.noreply.github.com>
---
tap_google_ads/report_definitions.py | 10 +++++--
tap_google_ads/streams.py | 36 ++++++++++++++++-------
tests/base.py | 20 ++++++-------
tests/test_google_ads_automatic_fields.py | 4 ++-
tests/test_google_ads_bookmarks.py | 1 +
tests/test_google_ads_start_date.py | 2 ++
tests/test_google_ads_sync_canary.py | 1 +
7 files changed, 49 insertions(+), 25 deletions(-)
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index 31c4f97..cf563f7 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -1,10 +1,14 @@
+# Core streams
+ACCESSIBLE_BIDDING_STRATEGY_FIELDS = []
ACCOUNT_FIELDS = []
-AD_GROUP_FIELDS = []
AD_GROUP_AD_FIELDS = []
-CAMPAIGN_FIELDS = []
+AD_GROUP_FIELDS = []
BIDDING_STRATEGY_FIELDS = []
-ACCESSIBLE_BIDDING_STRATEGY_FIELDS = []
+CALL_VIEW_FIELDS = []
CAMPAIGN_BUDGET_FIELDS = []
+CAMPAIGN_FIELDS = []
+
+# Report streams
ACCOUNT_PERFORMANCE_REPORT_FIELDS = [
"customer.auto_tagging_enabled",
"customer.currency_code",
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 60dd907..22f9618 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -164,6 +164,12 @@ def google_message_to_json(message):
return json.loads(json_string)
+def filter_out_non_attribute_fields(fields):
+ return {field_name: field_data
+ for field_name, field_data in fields.items()
+ if field_data["field_details"]["category"] == "ATTRIBUTE"}
+
+
class BaseStream: # pylint: disable=too-many-instance-attributes
def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys):
@@ -207,10 +213,11 @@ def add_extra_fields(self, resource_schema):
`behavior` that are not covered by Google's resource_schema
"""
+
def create_full_schema(self, resource_schema):
google_ads_name = self.google_ads_resource_names[0]
self.resource_object = resource_schema[google_ads_name]
- self.resource_fields = self.resource_object["fields"]
+ self.resource_fields = filter_out_non_attribute_fields(self.resource_object["fields"])
self.full_schema = create_nested_resource_schema(resource_schema, self.resource_fields)
def set_stream_schema(self):
@@ -247,9 +254,10 @@ def build_stream_metadata(self):
for field, props in self.resource_fields.items():
resource_matches = field.startswith(self.resource_object["name"] + ".")
+ is_attribute = props["field_details"]["category"] == "ATTRIBUTE"
is_id_field = field.endswith(".id")
- if is_id_field or (props["field_details"]["category"] == "ATTRIBUTE" and resource_matches):
+ if is_id_field or (is_attribute and resource_matches):
# Transform the field name to match the schema
# Special case for ads since they are nested under ad_group_ad and
# we have to bump them up a level
@@ -540,6 +548,12 @@ def sync(self, sdk_client, customer, stream, config, state):
def initialize_core_streams(resource_schema):
return {
+ "accessible_bidding_strategies": BaseStream(
+ report_definitions.ACCESSIBLE_BIDDING_STRATEGY_FIELDS,
+ ["accessible_bidding_strategy"],
+ resource_schema,
+ ["id"],
+ ),
"accounts": BaseStream(
report_definitions.ACCOUNT_FIELDS,
["customer"],
@@ -558,21 +572,21 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
),
- "campaigns": BaseStream(
- report_definitions.CAMPAIGN_FIELDS,
- ["campaign"],
- resource_schema,
- ["id"],
- ),
"bidding_strategies": BaseStream(
report_definitions.BIDDING_STRATEGY_FIELDS,
["bidding_strategy"],
resource_schema,
["id"],
),
- "accessible_bidding_strategies": BaseStream(
- report_definitions.ACCESSIBLE_BIDDING_STRATEGY_FIELDS,
- ["accessible_bidding_strategy"],
+ "call_details": BaseStream(
+ report_definitions.CALL_VIEW_FIELDS,
+ ["call_view"],
+ resource_schema,
+ ["resource_name"],
+ ),
+ "campaigns": BaseStream(
+ report_definitions.CAMPAIGN_FIELDS,
+ ["campaign"],
resource_schema,
["id"],
),
diff --git a/tests/base.py b/tests/base.py
index d7d2f6c..17d8f85 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -112,10 +112,7 @@ def expected_metadata(self):
'campaign_budgets': {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
- "customer_id",
- "campaign_id",
- },
+ self.FOREIGN_KEYS: {"customer_id"},
},
'bidding_strategies': {
self.PRIMARY_KEYS:{"id"},
@@ -127,6 +124,15 @@ def expected_metadata(self):
self.REPLICATION_METHOD: self.FULL_TABLE,
self.FOREIGN_KEYS: {"customer_id"},
},
+ 'call_details': {
+ self.PRIMARY_KEYS: {"resource_name"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ "ad_group_id",
+ "campaign_id",
+ "customer_id"
+ },
+ },
# Report objects
"age_range_performance_report": { # "age_range_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
@@ -143,12 +149,6 @@ def expected_metadata(self):
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- # TODO Post Beta
- # "call_metrics_call_details_report": { # "call_view"
- # self.PRIMARY_KEYS: {"_sdc_record_hash"},
- # self.REPLICATION_METHOD: self.INCREMENTAL,
- # self.REPLICATION_KEYS: {"date"},
- # },
"click_performance_report": { # "click_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
diff --git a/tests/test_google_ads_automatic_fields.py b/tests/test_google_ads_automatic_fields.py
index 4491697..4170ba8 100644
--- a/tests/test_google_ads_automatic_fields.py
+++ b/tests/test_google_ads_automatic_fields.py
@@ -78,7 +78,9 @@ def test_happy_path(self):
conn_id = connections.ensure_connection(self)
streams_to_test = {stream for stream in self.expected_streams()
- if not self.is_report(stream)}
+ if not self.is_report(stream)} - {
+ "call_details" # need test call data before data will be returned
+ }
# Run a discovery job
found_catalogs = self.run_and_verify_check_mode(conn_id)
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index a6cf857..370c0e2 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -54,6 +54,7 @@ def test_run(self):
'shopping_performance_report',
'video_performance_report',
'campaign_audience_performance_report',
+ 'call_details', # need test call data before data will be returned
}
# Run a discovery job
diff --git a/tests/test_google_ads_start_date.py b/tests/test_google_ads_start_date.py
index ce031bb..18e189d 100644
--- a/tests/test_google_ads_start_date.py
+++ b/tests/test_google_ads_start_date.py
@@ -156,6 +156,7 @@ class StartDateTest1(StartDateTest):
'ad_group_audience_performance_report',
"shopping_performance_report",
'campaign_audience_performance_report',
+ 'call_details',
}
def setUp(self):
@@ -163,6 +164,7 @@ def setUp(self):
self.start_date_2 = self.timedelta_formatted(self.start_date_1, days=15)
self.streams_to_test = self.expected_streams() - {
'search_query_performance_report', # Covered in other start date test
+ 'call_details', # Need test data
} - self.missing_coverage_streams # TODO_TDL-17885
@staticmethod
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index ca0765e..725a33c 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -86,6 +86,7 @@ def test_run(self):
'placement_performance_report', # need an app to run javascript to trace conversions
"video_performance_report", # need a video to show
"shopping_performance_report", # need Shopping campaign type, and link to a store
+ "call_details", # need test call data before data will be returned
}
# Run a discovery job
From af2ce48e3bc748b7eeafc19f8e4f1a442a0032c9 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Mon, 25 Apr 2022 13:20:59 -0400
Subject: [PATCH 42/69] Add core LABELS streams and campaign.labels fields to
relevant reports (#53)
* Add core LABELS streams and campaign.labels fields to relevant reports
* Update test exclusions to exclude new core streams.
* Update discover to include campaign_label in reports list
* Update foreign_key expected metadata to include attributed resource foreign_keys
* Update tests to re-include campaign_labels and labels as we now have test data.
---
tap_google_ads/discover.py | 2 +
tap_google_ads/report_definitions.py | 21 ++++++++++
tap_google_ads/streams.py | 14 ++++++-
tests/base.py | 50 +++++++++++++++--------
tests/test_google_ads_automatic_fields.py | 2 +-
5 files changed, 71 insertions(+), 18 deletions(-)
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
index 098e5b9..13c8861 100644
--- a/tap_google_ads/discover.py
+++ b/tap_google_ads/discover.py
@@ -21,6 +21,7 @@
"campaign_audience_view",
"campaign_budget",
"campaign_criterion",
+ "campaign_label",
"click_view",
"customer",
"display_keyword_view",
@@ -32,6 +33,7 @@
"gender_view",
"geographic_view",
"keyword_view",
+ "label",
"landing_page_view",
"managed_placement_view",
"search_term_view",
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index cf563f7..a7344cc 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -7,6 +7,8 @@
CALL_VIEW_FIELDS = []
CAMPAIGN_BUDGET_FIELDS = []
CAMPAIGN_FIELDS = []
+CAMPAIGN_LABEL_FIELDS = []
+LABEL_FIELDS = []
# Report streams
ACCOUNT_PERFORMANCE_REPORT_FIELDS = [
@@ -104,6 +106,7 @@
"campaign.bidding_strategy",
"campaign.bidding_strategy_type",
"campaign.id",
+ "campaign.labels",
"campaign.manual_cpc.enhanced_cpc_enabled",
"campaign.name",
"campaign.percent_cpc.enhanced_cpc_enabled",
@@ -293,6 +296,7 @@
"ad_group_ad.status",
"campaign.base_campaign",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -630,6 +634,7 @@
"campaign.experiment_type",
"campaign.final_url_suffix",
"campaign.id",
+ "campaign.labels",
"campaign.manual_cpc.enhanced_cpc_enabled",
"campaign.maximize_conversion_value.target_roas",
"campaign.name",
@@ -748,6 +753,7 @@
"ad_group.name",
"ad_group.status",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"click_view.ad_group_ad",
@@ -803,6 +809,7 @@
"campaign.bidding_strategy",
"campaign.bidding_strategy_type",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -892,6 +899,7 @@
"campaign.bidding_strategy",
"campaign.bidding_strategy_type",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -962,6 +970,7 @@
"ad_group.status",
"campaign.advertising_channel_type",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"expanded_landing_page_view.expanded_final_url",
@@ -1034,6 +1043,7 @@
"campaign.base_campaign",
"campaign.bidding_strategy",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -1103,6 +1113,7 @@
"ad_group.name",
"ad_group.status",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -1160,6 +1171,7 @@
"ad_group.name",
"ad_group.status",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -1234,6 +1246,7 @@
"campaign.bidding_strategy",
"campaign.bidding_strategy_type",
"campaign.id",
+ "campaign.labels",
"campaign.manual_cpc.enhanced_cpc_enabled",
"campaign.name",
"campaign.percent_cpc.enhanced_cpc_enabled",
@@ -1333,6 +1346,7 @@
"ad_group.status",
"campaign.advertising_channel_type",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"landing_page_view.unexpanded_final_url",
@@ -1387,6 +1401,7 @@
"ad_group.status",
"ad_group_ad.resource_name",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -1454,6 +1469,7 @@
"ad_group.status",
"ad_group_ad.resource_name",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.descriptive_name",
@@ -1527,6 +1543,7 @@
"campaign.base_campaign",
"campaign.bidding_strategy",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -1599,6 +1616,7 @@
"ad_group_ad.ad.id",
"ad_group_ad.ad.tracking_url_template",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -1664,6 +1682,7 @@
"ad_group.name",
"ad_group.status",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.descriptive_name",
@@ -1732,6 +1751,7 @@
"ad_group.name",
"ad_group.status",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
@@ -1791,6 +1811,7 @@
"ad_group_ad.ad.id",
"ad_group_ad.status",
"campaign.id",
+ "campaign.labels",
"campaign.name",
"campaign.status",
"customer.currency_code",
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 22f9618..587958f 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -278,7 +278,7 @@ def build_stream_metadata(self):
# Add inclusion metadata
# Foreign keys are automatically included and they are all id fields
- if field in self.primary_keys or field in {'customer_id', 'ad_group_id', 'campaign_id'}:
+ if field in self.primary_keys or field in {'customer_id', 'ad_group_id', 'campaign_id', 'label_id'}:
inclusion = "automatic"
elif props["field_details"]["selectable"]:
inclusion = "available"
@@ -596,6 +596,18 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
),
+ "campaign_labels": BaseStream(
+ report_definitions.CAMPAIGN_LABEL_FIELDS,
+ ["campaign_label"],
+ resource_schema,
+ ["resource_name"],
+ ),
+ "labels": BaseStream(
+ report_definitions.LABEL_FIELDS,
+ ["label"],
+ resource_schema,
+ ["id"],
+ ),
}
diff --git a/tests/base.py b/tests/base.py
index 17d8f85..04e7e8a 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -80,17 +80,15 @@ def expected_metadata(self):
"""
return {
# Core Objects
- "accounts": {
+ 'accessible_bidding_strategies': {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: set(),
+ self.FOREIGN_KEYS: {"customer_id"},
},
- "campaigns": {
+ "accounts": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
- 'customer_id'
- },
+ self.FOREIGN_KEYS: set(),
},
"ad_groups": {
self.PRIMARY_KEYS: {"id"},
@@ -109,21 +107,11 @@ def expected_metadata(self):
"ad_group_id"
},
},
- 'campaign_budgets': {
- self.PRIMARY_KEYS: {"id"},
- self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {"customer_id"},
- },
'bidding_strategies': {
self.PRIMARY_KEYS:{"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.FOREIGN_KEYS: {"customer_id"},
},
- 'accessible_bidding_strategies': {
- self.PRIMARY_KEYS: {"id"},
- self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {"customer_id"},
- },
'call_details': {
self.PRIMARY_KEYS: {"resource_name"},
self.REPLICATION_METHOD: self.FULL_TABLE,
@@ -133,6 +121,36 @@ def expected_metadata(self):
"customer_id"
},
},
+ "campaigns": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ 'customer_id'
+ },
+ },
+ 'campaign_budgets': {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ "customer_id"
+ },
+ },
+ 'campaign_labels': {
+ self.PRIMARY_KEYS: {"resource_name"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ "customer_id",
+ "campaign_id",
+ "label_id"
+ },
+ },
+ 'labels': {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.FOREIGN_KEYS: {
+ "customer_id"
+ },
+ },
# Report objects
"age_range_performance_report": { # "age_range_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
diff --git a/tests/test_google_ads_automatic_fields.py b/tests/test_google_ads_automatic_fields.py
index 4170ba8..16c37eb 100644
--- a/tests/test_google_ads_automatic_fields.py
+++ b/tests/test_google_ads_automatic_fields.py
@@ -79,7 +79,7 @@ def test_happy_path(self):
streams_to_test = {stream for stream in self.expected_streams()
if not self.is_report(stream)} - {
- "call_details" # need test call data before data will be returned
+ "call_details", # need test call data before data will be returned
}
# Run a discovery job
From 3aca803f2787be0c8d383b1a4fb21192bfc453e6 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Mon, 25 Apr 2022 16:42:17 -0400
Subject: [PATCH 43/69] Handles case where state does not have
currently_syncing (#54)
* Handles case where state does not have currently_syncing
* tap-tester test added
* takeout unused imports in test
Co-authored-by: kspeer
---
tap_google_ads/sync.py | 2 +-
tests/test_google_ads_no_selection.py | 48 +++++++++++++++++++++++++++
2 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 tests/test_google_ads_no_selection.py
diff --git a/tap_google_ads/sync.py b/tap_google_ads/sync.py
index 983264e..3f2e81c 100644
--- a/tap_google_ads/sync.py
+++ b/tap_google_ads/sync.py
@@ -110,5 +110,5 @@ def do_sync(config, catalog, resource_schema, state):
stream_obj.sync(sdk_client, customer, catalog_entry, config, state)
- state.pop("currently_syncing")
+ state.pop("currently_syncing", None)
singer.write_state(state)
diff --git a/tests/test_google_ads_no_selection.py b/tests/test_google_ads_no_selection.py
new file mode 100644
index 0000000..3f536df
--- /dev/null
+++ b/tests/test_google_ads_no_selection.py
@@ -0,0 +1,48 @@
+"""Test tap can handle running a sync with no streams selected."""
+from tap_tester import menagerie, connections, runner, LOGGER
+
+from base import GoogleAdsBase
+
+
+class NoStreamsSelected(GoogleAdsBase):
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_no_streams"
+
+ @staticmethod
+ def streams_to_test():
+ """No streams are selected."""
+ return set()
+
+ def test_run(self):
+ """
+ Verify tap can perform sync without Critical Error even if no streams are
+ selected for replication.
+ """
+
+ LOGGER.info(
+ "Field Exclusion Test with random field selection for tap-google-ads report streams.\n"
+ f"Streams Under Test: {self.streams_to_test}"
+ )
+
+ conn_id = connections.ensure_connection(self)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ # Run a sync job using orchestrator WITHOUT selecting streams
+ sync_job_name = runner.run_sync_mode(self, conn_id)
+
+ # Verify tap and target do not throw any errors
+ exit_status = menagerie.get_exit_status(conn_id, sync_job_name)
+ menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
+
+ # Verify no records were replicated
+ sync_record_count = runner.examine_target_output_file(
+ self, conn_id, self.expected_streams(), self.expected_primary_keys())
+ self.assertEqual(sum(sync_record_count.values()), 0)
+
+ # Verify state is empty
+ state = menagerie.get_state(conn_id)
+ self.assertDictEqual(dict(), state)
From 5e019075074237b8553d40b1f057b2e2c5939cf2 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Mon, 25 Apr 2022 16:45:40 -0400
Subject: [PATCH 44/69] Version bump and changelog entry (#52)
* Version bump and changelog entry
* Update to include PR 53.
* Add PR 54 to changelog
---
CHANGELOG.md | 5 +++++
setup.py | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b8fa42..f17648a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v1.1.0
+ * Fixes a bug with currently_syncing and adds tests around the bug fix [#54](https://github.com/singer-io/tap-google-ads/pull/54)
+ * Adds `campaign_labels` and `labels` core streams; adds "campaign.labels" field to reports where relevant [#53](https://github.com/singer-io/tap-google-ads/pull/53)
+ * Adds `call_details` core stream and removes segmenting resources from core streams [#49](https://github.com/singer-io/tap-google-ads/pull/49)
+
## v1.0.0
* Version bump for GA release
* Adds fields to click_view report definition [#44](https://github.com/singer-io/tap-google-ads/pull/44)
diff --git a/setup.py b/setup.py
index 416a5c9..c34b239 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.0.0',
+ version='1.1.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From 66617d4b01d8998608fa5cd70907b9bd7f00de7d Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Mon, 9 May 2022 10:10:10 -0400
Subject: [PATCH 45/69] Implement Automatic Keys (#55)
* Set geographic_view.location_type as automatic to account for reporting discrepancies
* Update Automatic Fields test happy / error paths
* Update base to include automatic_keys metadata for use in tests
* WIP Add Automatic Report Fields
* Pass automatic_keys to BaseStream; use automatic_keys for inclusion
* Fix bad field name in streams; Start updating tests
* Rename function to match base.py
* Add closing brace -_-
* Fix tests; rename ad_group_criterion_criterion_id in automatic_keys; reorder test metadata; add campaign.id to campaign_audience_performance_report
* Rename report_field_parts to split_report_field
* Update transform_keys to raise ad_group_ad.ad fields
* Update happy path streams_to_test to exclude streams with no data
* Remove field name change for ad_performance_report stream
* Accept Andy's Suggestion
Co-authored-by: Andy Lu
* Reverting Andy's change because it affects core streams
* Explicitly install grpcio-status to avoid from_call attribute errors
* Update setup.py with docs explaining required but unused dep
Co-authored-by: Andy Lu
Co-authored-by: Arthur Gorka
---
setup.py | 3 +
tap_google_ads/report_definitions.py | 10 ++
tap_google_ads/streams.py | 114 +++++++++++++--
tests/base.py | 163 ++++++++++++++++------
tests/test_google_ads_automatic_fields.py | 20 ++-
tests/test_google_ads_discovery.py | 4 +-
6 files changed, 254 insertions(+), 60 deletions(-)
diff --git a/setup.py b/setup.py
index c34b239..324c5a1 100644
--- a/setup.py
+++ b/setup.py
@@ -15,6 +15,9 @@
'backoff==1.8.0',
'google-ads==15.0.0',
'protobuf==3.17.3',
+ # Necessary to handle gRPC exceptions properly, documented
+ # in an issue here: https://github.com/googleapis/python-api-core/issues/301
+ 'grpcio-status==1.44.0',
],
extras_require= {
'dev': [
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index a7344cc..9d7f33b 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -556,9 +556,13 @@
"campaign.base_campaign",
"campaign.bidding_strategy",
"campaign.bidding_strategy_type",
+ "campaign.id",
"campaign.name",
"campaign.status",
+ "campaign_criterion.age_range.type",
"campaign_criterion.bid_modifier",
+ "campaign_criterion.criterion_id",
+ "campaign_criterion.combined_audience.combined_audience",
"customer.currency_code",
"customer.descriptive_name",
"customer.descriptive_name",
@@ -1181,6 +1185,7 @@
"customer.time_zone",
"dynamic_search_ads_search_term_view.headline",
"dynamic_search_ads_search_term_view.landing_page",
+ "dynamic_search_ads_search_term_view.page_url",
"dynamic_search_ads_search_term_view.search_term",
"metrics.all_conversions",
"metrics.all_conversions_from_interactions_rate",
@@ -1409,6 +1414,11 @@
"customer.descriptive_name",
"customer.id",
"customer.time_zone",
+ "feed.attributes",
+ "feed.id",
+ "feed.name",
+ "feed.origin",
+ "feed.status",
"feed_item.attribute_values",
"feed_item.end_date_time",
"feed_item.feed",
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 587958f..0748cbc 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -172,11 +172,11 @@ def filter_out_non_attribute_fields(fields):
class BaseStream: # pylint: disable=too-many-instance-attributes
- def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys):
+ def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys, automatic_keys = None):
self.fields = fields
self.google_ads_resource_names = google_ads_resource_names
self.primary_keys = primary_keys
-
+ self.automatic_keys = automatic_keys if automatic_keys else set()
self.extract_field_information(resource_schema)
self.create_full_schema(resource_schema)
@@ -278,7 +278,7 @@ def build_stream_metadata(self):
# Add inclusion metadata
# Foreign keys are automatically included and they are all id fields
- if field in self.primary_keys or field in {'customer_id', 'ad_group_id', 'campaign_id', 'label_id'}:
+ if field in self.primary_keys or field in self.automatic_keys:
inclusion = "automatic"
elif props["field_details"]["selectable"]:
inclusion = "available"
@@ -365,6 +365,7 @@ def get_query_date(start_date, bookmark, conversion_window_date):
class ReportStream(BaseStream):
+
def create_full_schema(self, resource_schema):
google_ads_name = self.google_ads_resource_names[0]
self.resource_object = resource_schema[google_ads_name]
@@ -441,7 +442,7 @@ def build_stream_metadata(self):
# Add inclusion metadata
if self.behavior[report_field]:
inclusion = "available"
- if report_field == "segments.date":
+ if transformed_field_name in ({"date"} | self.automatic_keys):
inclusion = "automatic"
else:
inclusion = "unsupported"
@@ -553,6 +554,7 @@ def initialize_core_streams(resource_schema):
["accessible_bidding_strategy"],
resource_schema,
["id"],
+ {"customer_id"},
),
"accounts": BaseStream(
report_definitions.ACCOUNT_FIELDS,
@@ -565,48 +567,71 @@ def initialize_core_streams(resource_schema):
["ad_group"],
resource_schema,
["id"],
+ {
+ "campaign_id",
+ "customer_id",
+ },
),
"ads": BaseStream(
report_definitions.AD_GROUP_AD_FIELDS,
["ad_group_ad"],
resource_schema,
["id"],
+ {
+ "ad_group_id",
+ "campaign_id",
+ "customer_id",
+ },
),
"bidding_strategies": BaseStream(
report_definitions.BIDDING_STRATEGY_FIELDS,
["bidding_strategy"],
resource_schema,
["id"],
+ {"customer_id"},
),
"call_details": BaseStream(
report_definitions.CALL_VIEW_FIELDS,
["call_view"],
resource_schema,
["resource_name"],
+ {
+ "ad_group_id",
+ "campaign_id",
+ "customer_id",
+ },
),
"campaigns": BaseStream(
report_definitions.CAMPAIGN_FIELDS,
["campaign"],
resource_schema,
["id"],
+ {"customer_id"},
),
"campaign_budgets": BaseStream(
report_definitions.CAMPAIGN_BUDGET_FIELDS,
["campaign_budget"],
resource_schema,
["id"],
+ {"customer_id"},
),
"campaign_labels": BaseStream(
report_definitions.CAMPAIGN_LABEL_FIELDS,
["campaign_label"],
resource_schema,
["resource_name"],
+ {
+ "campaign_id",
+ "customer_id",
+ "label_id",
+ },
),
"labels": BaseStream(
report_definitions.LABEL_FIELDS,
["label"],
resource_schema,
["id"],
+ {"customer_id"},
),
}
@@ -618,120 +643,184 @@ def initialize_reports(resource_schema):
["customer"],
resource_schema,
["_sdc_record_hash"],
- ),
- "ad_group_performance_report": ReportStream(
- report_definitions.AD_GROUP_PERFORMANCE_REPORT_FIELDS,
- ["ad_group"],
- resource_schema,
- ["_sdc_record_hash"],
+ {"customer_id"},
),
"ad_group_audience_performance_report": ReportStream(
report_definitions.AD_GROUP_AUDIENCE_PERFORMANCE_REPORT_FIELDS,
["ad_group_audience_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
+ ),
+ "ad_group_performance_report": ReportStream(
+ report_definitions.AD_GROUP_PERFORMANCE_REPORT_FIELDS,
+ ["ad_group"],
+ resource_schema,
+ ["_sdc_record_hash"],
+ {"ad_group_id"},
),
"ad_performance_report": ReportStream(
report_definitions.AD_PERFORMANCE_REPORT_FIELDS,
["ad_group_ad"],
resource_schema,
["_sdc_record_hash"],
+ {"id"},
),
"age_range_performance_report": ReportStream(
report_definitions.AGE_RANGE_PERFORMANCE_REPORT_FIELDS,
["age_range_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_criterion_age_range",
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
),
"campaign_performance_report": ReportStream(
report_definitions.CAMPAIGN_PERFORMANCE_REPORT_FIELDS,
["campaign"],
resource_schema,
["_sdc_record_hash"],
+ {"campaign_id"},
),
"campaign_audience_performance_report": ReportStream(
report_definitions.CAMPAIGN_AUDIENCE_PERFORMANCE_REPORT_FIELDS,
["campaign_audience_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "campaign_id",
+ "campaign_criterion_criterion_id",
+ },
),
"click_performance_report": ReportStream(
report_definitions.CLICK_PERFORMANCE_REPORT_FIELDS,
["click_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "clicks",
+ "click_view_gclid",
+ },
),
"display_keyword_performance_report": ReportStream(
report_definitions.DISPLAY_KEYWORD_PERFORMANCE_REPORT_FIELDS,
["display_keyword_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
),
"display_topics_performance_report": ReportStream(
report_definitions.DISPLAY_TOPICS_PERFORMANCE_REPORT_FIELDS,
["topic_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
),
"expanded_landing_page_report": ReportStream(
report_definitions.EXPANDED_LANDING_PAGE_REPORT_FIELDS,
["expanded_landing_page_view"],
resource_schema,
["_sdc_record_hash"],
+ {"expanded_landing_page_view_expanded_final_url"},
),
"gender_performance_report": ReportStream(
report_definitions.GENDER_PERFORMANCE_REPORT_FIELDS,
["gender_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
),
"geo_performance_report": ReportStream(
report_definitions.GEO_PERFORMANCE_REPORT_FIELDS,
["geographic_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "geographic_view_country_criterion_id",
+ "geographic_view_location_type",
+ },
),
"keywordless_query_report": ReportStream(
report_definitions.KEYWORDLESS_QUERY_REPORT_FIELDS,
["dynamic_search_ads_search_term_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_id",
+ "dynamic_search_ads_search_term_view_headline",
+ "dynamic_search_ads_search_term_view_landing_page",
+ "dynamic_search_ads_search_term_view_page_url",
+ "dynamic_search_ads_search_term_view_search_term",
+ },
),
"keywords_performance_report": ReportStream(
report_definitions.KEYWORDS_PERFORMANCE_REPORT_FIELDS,
["keyword_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
),
"landing_page_report": ReportStream(
report_definitions.LANDING_PAGE_REPORT_FIELDS,
["landing_page_view"],
resource_schema,
["_sdc_record_hash"],
+ {"landing_page_view_unexpanded_final_url"},
),
"placeholder_feed_item_report": ReportStream(
report_definitions.PLACEHOLDER_FEED_ITEM_REPORT_FIELDS,
["feed_item"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "feed_id",
+ "feed_item_id",
+ }
),
"placeholder_report": ReportStream(
report_definitions.PLACEHOLDER_REPORT_FIELDS,
["feed_placeholder_view"],
resource_schema,
["_sdc_record_hash"],
+ {"feed_placeholder_view_placeholder_type"},
),
"placement_performance_report": ReportStream(
report_definitions.PLACEMENT_PERFORMANCE_REPORT_FIELDS,
["managed_placement_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
),
"search_query_performance_report": ReportStream(
report_definitions.SEARCH_QUERY_PERFORMANCE_REPORT_FIELDS,
["search_term_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "ad_group_id",
+ "campaign_id",
+ "search_term_view_search_term",
+ },
),
"shopping_performance_report": ReportStream(
report_definitions.SHOPPING_PERFORMANCE_REPORT_FIELDS,
@@ -744,11 +833,16 @@ def initialize_reports(resource_schema):
["user_location_view"],
resource_schema,
["_sdc_record_hash"],
+ {
+ "user_location_view_country_criterion_id",
+ "user_location_view_targeting_location",
+ },
),
"video_performance_report": ReportStream(
report_definitions.VIDEO_PERFORMANCE_REPORT_FIELDS,
["video"],
resource_schema,
["_sdc_record_hash"],
+ {"video_id"},
),
}
diff --git a/tests/base.py b/tests/base.py
index 04e7e8a..1194fa2 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -22,7 +22,7 @@ class GoogleAdsBase(unittest.TestCase):
AUTOMATIC_FIELDS = "automatic"
REPLICATION_KEYS = "valid-replication-keys"
PRIMARY_KEYS = "table-key-properties"
- FOREIGN_KEYS = "table-foreign-key-properties"
+ AUTOMATIC_KEYS = "table-automatic-key-properties"
REPLICATION_METHOD = "forced-replication-method"
INCREMENTAL = "INCREMENTAL"
FULL_TABLE = "FULL_TABLE"
@@ -83,25 +83,26 @@ def expected_metadata(self):
'accessible_bidding_strategies': {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {"customer_id"},
+ self.AUTOMATIC_KEYS: {"customer_id"},
},
"accounts": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: set(),
+ self.AUTOMATIC_KEYS: set(),
},
"ad_groups": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
+ self.AUTOMATIC_KEYS: {
'campaign_id',
'customer_id',
},
+
},
"ads": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
+ self.AUTOMATIC_KEYS: {
"campaign_id",
"customer_id",
"ad_group_id"
@@ -110,12 +111,12 @@ def expected_metadata(self):
'bidding_strategies': {
self.PRIMARY_KEYS:{"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {"customer_id"},
+ self.AUTOMATIC_KEYS: {"customer_id"},
},
'call_details': {
self.PRIMARY_KEYS: {"resource_name"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
+ self.AUTOMATIC_KEYS: {
"ad_group_id",
"campaign_id",
"customer_id"
@@ -124,21 +125,21 @@ def expected_metadata(self):
"campaigns": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
+ self.AUTOMATIC_KEYS: {
'customer_id'
},
},
'campaign_budgets': {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
+ self.AUTOMATIC_KEYS: {
"customer_id"
},
},
'campaign_labels': {
self.PRIMARY_KEYS: {"resource_name"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
+ self.AUTOMATIC_KEYS: {
"customer_id",
"campaign_id",
"label_id"
@@ -147,130 +148,200 @@ def expected_metadata(self):
'labels': {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.FOREIGN_KEYS: {
- "customer_id"
+ self.AUTOMATIC_KEYS: {
+ "customer_id",
},
},
# Report objects
- "age_range_performance_report": { # "age_range_view"
- self.PRIMARY_KEYS: {"_sdc_record_hash"},
- self.REPLICATION_METHOD: self.INCREMENTAL,
- self.REPLICATION_KEYS: {"date"},
- },
- "campaign_performance_report": { # "campaign"
+
+ # All reports have AUTOMATIC_KEYS that we include to delineate reporting data downstream.
+ # These are fields that are inherently used by Google for each respective resource to aggregate metrics
+ # shopping_performance_report's automatic_keys are currently unknown, and thus are temporarily empty
+
+ "account_performance_report": { # accounts
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"customer_id"},
},
- "campaign_audience_performance_report": { # "campaign_audience_view"
+ "ad_group_audience_performance_report": { # ad_group_audience_view
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
},
- "click_performance_report": { # "click_view"
+ "ad_group_performance_report": { # ad_group
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"ad_group_id"},
},
- "display_keyword_performance_report": { # "display_keyword_view"
+ "ad_performance_report": { # ads
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"id"},
},
- "display_topics_performance_report": { # "topic_view"
+ "age_range_performance_report": { # "age_range_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_criterion_age_range",
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
},
- "gender_performance_report": { # "gender_view"
+ "campaign_audience_performance_report": { # "campaign_audience_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "campaign_id",
+ "campaign_criterion_criterion_id",
+ },
},
- "geo_performance_report": { # "geographic_view"
+ "campaign_performance_report": { # "campaign"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"campaign_id"}
},
- "user_location_performance_report": { # "user_location_view"
+
+ "click_performance_report": { # "click_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "clicks", # This metric is automatically included because it is the only metric available via the report
+ "click_view_gclid",
+ },
},
- "keywordless_query_report": { # "dynamic_search_ads_search_term_view"
+ "display_keyword_performance_report": { # "display_keyword_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
},
- "keywords_performance_report": { # "keyword_view"
+ "display_topics_performance_report": { # "topic_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
},
- "landing_page_report": {
+ "expanded_landing_page_report": {
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"expanded_landing_page_view_expanded_final_url"},
},
- "expanded_landing_page_report": {
+ "gender_performance_report": { # "gender_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
},
- "placeholder_feed_item_report": { # "feed_item", "feed_item_target"
+ "geo_performance_report": { # "geographic_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "geographic_view_country_criterion_id",
+ "geographic_view_location_type",
+ }
},
- "placeholder_report": { # "feed_placeholder_view"
+ "keywordless_query_report": { # "dynamic_search_ads_search_term_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_id",
+ "dynamic_search_ads_search_term_view_headline",
+ "dynamic_search_ads_search_term_view_landing_page",
+ "dynamic_search_ads_search_term_view_page_url",
+ "dynamic_search_ads_search_term_view_search_term",
+ },
},
- "placement_performance_report": { # "managed_placement_view"
+ "keywords_performance_report": { # "keyword_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
},
- "search_query_performance_report": { # "search_term_view"
+ "landing_page_report": {
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"landing_page_view_unexpanded_final_url"},
},
- "shopping_performance_report": { # "shopping_performance_view"
+ "placeholder_feed_item_report": { # "feed_item", "feed_item_target"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "feed_id",
+ "feed_item_id",
+ },
},
- "user_location_performance_report": { # "user_location_view"
+ "placeholder_report": { # "feed_placeholder_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"feed_placeholder_view_placeholder_type"},
},
- "video_performance_report": { # "video"
+ "placement_performance_report": { # "managed_placement_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_criterion_criterion_id",
+ "ad_group_id",
+ },
},
- "account_performance_report": { # accounts
+ "search_query_performance_report": { # "search_term_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "ad_group_id",
+ "campaign_id",
+ "search_term_view_search_term",
+ },
},
- "ad_group_performance_report": { # ad_group
+ "shopping_performance_report": { # "shopping_performance_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
},
- "ad_group_audience_performance_report": { # ad_group_audience_view
+ "user_location_performance_report": { # "user_location_view"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {
+ "user_location_view_country_criterion_id",
+ "user_location_view_targeting_location",
+ },
},
- "ad_performance_report": { # ads
+ "video_performance_report": { # "video"
self.PRIMARY_KEYS: {"_sdc_record_hash"},
self.REPLICATION_METHOD: self.INCREMENTAL,
self.REPLICATION_KEYS: {"date"},
+ self.AUTOMATIC_KEYS: {"video_id"},
},
# Custom Reports TODO Post Beta feature
@@ -280,12 +351,12 @@ def expected_streams(self):
"""A set of expected stream names"""
return set(self.expected_metadata().keys())
- def expected_foreign_keys(self):
+ def expected_automatic_keys(self):
"""
return a dictionary with key of table name
- and value as a set of foreign key fields
+ and value as a set of automatic key fields
"""
- return {table: properties.get(self.FOREIGN_KEYS, set())
+ return {table: properties.get(self.AUTOMATIC_KEYS, set())
for table, properties
in self.expected_metadata().items()}
@@ -308,10 +379,14 @@ def expected_replication_keys(self):
in self.expected_metadata().items()}
def expected_automatic_fields(self):
+ """
+ return a dictionary with key of table name
+ and value as a set of all inclusion == automatic fields
+ """
auto_fields = {}
for k, v in self.expected_metadata().items():
auto_fields[k] = v.get(self.PRIMARY_KEYS, set()) | v.get(self.REPLICATION_KEYS, set()) | \
- v.get(self.FOREIGN_KEYS, set())
+ v.get(self.AUTOMATIC_KEYS, set())
return auto_fields
diff --git a/tests/test_google_ads_automatic_fields.py b/tests/test_google_ads_automatic_fields.py
index 16c37eb..171a442 100644
--- a/tests/test_google_ads_automatic_fields.py
+++ b/tests/test_google_ads_automatic_fields.py
@@ -25,7 +25,7 @@ def test_error_case(self):
# --- Test report streams throw an error --- #
streams_to_test = {stream for stream in self.expected_streams()
- if self.is_report(stream)}
+ if stream == "shopping_performance_report"} # All other reports have automatic_keys
conn_id = connections.ensure_connection(self)
@@ -71,16 +71,28 @@ def test_happy_path(self):
"""
Testing that basic sync with minimum field selection functions without Critical Errors
"""
- print("Automatic Fields Test for tap-google-ads core streams")
+ print("Automatic Fields Test for tap-google-ads core streams and most reports")
# --- Start testing core streams --- #
conn_id = connections.ensure_connection(self)
streams_to_test = {stream for stream in self.expected_streams()
- if not self.is_report(stream)} - {
+ if stream not in {
+ # no test data available, but can generate
+ 'display_keyword_performance_report', # Singer Display #2, Ad Group 2
+ 'display_topics_performance_report', # Singer Display #2, Ad Group 2
+ "keywords_performance_report", # needs a Search Campaign (currently have none)
+ # audiences are unclear on how metrics fall into segments
+ 'campaign_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ 'ad_group_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ # cannot generate test data
+ 'placement_performance_report', # need an app to run javascript to trace conversions
+ "video_performance_report", # need a video to show
+ "shopping_performance_report", # need Shopping campaign type, and link to a store
"call_details", # need test call data before data will be returned
- }
+ "shopping_performance_report", # No automatic keys for this report
+ }}
# Run a discovery job
found_catalogs = self.run_and_verify_check_mode(conn_id)
diff --git a/tests/test_google_ads_discovery.py b/tests/test_google_ads_discovery.py
index 13f1e68..babb53a 100644
--- a/tests/test_google_ads_discovery.py
+++ b/tests/test_google_ads_discovery.py
@@ -55,9 +55,9 @@ def test_run(self):
# collecting expected values from base.py
expected_primary_keys = self.expected_primary_keys()[stream]
- expected_foreign_keys = self.expected_foreign_keys()[stream]
+ expected_automatic_keys = self.expected_automatic_keys()[stream]
expected_replication_keys = self.expected_replication_keys()[stream]
- expected_automatic_fields = expected_primary_keys | expected_replication_keys | expected_foreign_keys
+ expected_automatic_fields = self.expected_automatic_fields()[stream]
expected_replication_method = self.expected_replication_method()[stream]
is_report = self.is_report(stream)
expected_behaviors = {'METRIC', 'SEGMENT', 'ATTRIBUTE', 'PRIMARY KEY'} if is_report else {'ATTRIBUTE', 'SEGMENT'}
From f4d012788d0eccf913642ba994877eaecf505164 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Mon, 9 May 2022 11:10:51 -0400
Subject: [PATCH 46/69] Cleanup (#56)
* Remove useless function
* Rename "REPORTS" to "STREAMS" for accuracy / readability
---
tap_google_ads/discover.py | 14 +++++++-------
tap_google_ads/streams.py | 6 ------
2 files changed, 7 insertions(+), 13 deletions(-)
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
index 13c8861..7e755be 100644
--- a/tap_google_ads/discover.py
+++ b/tap_google_ads/discover.py
@@ -9,7 +9,7 @@
LOGGER = singer.get_logger()
-REPORTS = [
+STREAMS = [
"accessible_bidding_strategy",
"ad_group",
"ad_group_ad",
@@ -176,12 +176,12 @@ def create_resource_schema(config):
updated_segments = get_segments(resource_schema, resource)
resource["segments"] = updated_segments
- for report in REPORTS:
- report_object = resource_schema[report]
+ for stream in STREAMS:
+ stream_object = resource_schema[stream]
fields = {}
- attributes = report_object["attributes"]
- metrics = report_object["metrics"]
- segments = report_object["segments"]
+ attributes = stream_object["attributes"]
+ metrics = stream_object["metrics"]
+ segments = stream_object["segments"]
for field in attributes + metrics + segments:
field_schema = dict(resource_schema[field])
@@ -224,7 +224,7 @@ def create_resource_schema(config):
):
field["incompatible_fields"].append(compared_field)
- report_object["fields"] = fields
+ stream_object["fields"] = fields
return resource_schema
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 0748cbc..f295f70 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -205,14 +205,8 @@ def extract_field_information(self, resource_schema):
self.behavior[field_name] = field["field_details"]["category"]
- self.add_extra_fields(resource_schema)
self.field_exclusions = {k: list(v) for k, v in self.field_exclusions.items()}
- def add_extra_fields(self, resource_schema):
- """This function should add fields to `field_exclusions`, `schema`, and
- `behavior` that are not covered by Google's resource_schema
- """
-
def create_full_schema(self, resource_schema):
google_ads_name = self.google_ads_resource_names[0]
From 86b5c0df990221a9bcd5868a4f9589df28475e1b Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Mon, 9 May 2022 11:53:08 -0400
Subject: [PATCH 47/69] Version bump for PRs 56 and 55 (#57)
* Version bump for PRs 56 and 55
* Update to exclude ad_group_ad change for ad_performance_report
---
CHANGELOG.md | 4 ++++
setup.py | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f17648a..23c6cb2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## v1.2.0
+ * Renames `REPORTS` variable to `STREAMS` and updates corresponding variables similarly. Removes unused `add_extra_fields` function [#56](https://github.com/singer-io/tap-google-ads/pull/56)
+ * Adds `automatic_keys` to metadata for streams, including reports. Updates tests [#55](https://github.com/singer-io/tap-google-ads/pull/55)
+
## v1.1.0
* Fixes a bug with currently_syncing and adds tests around the bug fix [#54](https://github.com/singer-io/tap-google-ads/pull/54)
* Adds `campaign_labels` and `labels` core streams; adds "campaign.labels" field to reports where relevant [#53](https://github.com/singer-io/tap-google-ads/pull/53)
diff --git a/setup.py b/setup.py
index 324c5a1..6ea65d0 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.1.0',
+ version='1.2.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From 2c3bddefb4e807ec546b0bd832fbcd6aee3248fc Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Wed, 11 May 2022 09:48:33 -0400
Subject: [PATCH 48/69] [Feature] Add more core streams (#58)
* WIP add new core streams
* Set geographic_view.location_type as automatic to account for reporting discrepancies
* Update Automatic Fields test happy / error paths
* Update base to include automatic_keys metadata for use in tests
* WIP Add Automatic Report Fields
* Pass automatic_keys to BaseStream; use automatic_keys for inclusion
* Fix bad field name in streams; Start updating tests
* Rename function to match base.py
* Add closing brace -_-
* Fix tests; rename ad_group_criterion_criterion_id in automatic_keys; reorder test metadata; add campaign.id to campaign_audience_performance_report
* Rename report_field_parts to split_report_field
* Update transform_keys to raise ad_group_ad.ad fields
* Update happy path streams_to_test to exclude streams with no data
* WIP remove IPDB and try except
* Fix report_definition typo and add handling for user_interest_id field
* Remove field name change for ad_performance_report stream
* Accept Andy's Suggestion
Co-authored-by: Andy Lu
* Reverting Andy's change because it affects core streams
* Explicitly install grpcio-status to avoid from_call attribute errors
* Remove ipdb -_-
* Update setup.py with docs explaining required but unused dep
* WIP w/ failing tests and attributed_resource foreign_keys
* WIP with failing tests; remove extraneous attributed_resoruce automatic fields
* Create UserInterestStream class to handle its edge case
* Add transform_keys to UserInterestStream class; start test updates
* Rename obj to json_message; rename variables accordingly; fix UserInterestStream transform_keys
* update auto fields test to account for compound pks
* Fix campaigns typo
* Remove exclusion of feed and feed items from sync canary test
Co-authored-by: Andy Lu
Co-authored-by: Arthur Gorka
Co-authored-by: kspeer
Co-authored-by: atribed
---
tap_google_ads/discover.py | 11 +
tap_google_ads/report_definitions.py | 12 ++
tap_google_ads/streams.py | 204 +++++++++++++++---
tests/base.py | 93 ++++++--
tests/test_google_ads_automatic_fields.py | 32 +--
tests/test_google_ads_conversion_window.py | 2 +-
...st_google_ads_conversion_window_invalid.py | 2 +-
tests/test_google_ads_sync_canary.py | 13 +-
8 files changed, 306 insertions(+), 63 deletions(-)
diff --git a/tap_google_ads/discover.py b/tap_google_ads/discover.py
index 7e755be..57391cd 100644
--- a/tap_google_ads/discover.py
+++ b/tap_google_ads/discover.py
@@ -13,6 +13,7 @@
"accessible_bidding_strategy",
"ad_group",
"ad_group_ad",
+ "ad_group_criterion",
"ad_group_audience_view",
"age_range_view",
"bidding_strategy",
@@ -22,11 +23,13 @@
"campaign_budget",
"campaign_criterion",
"campaign_label",
+ "carrier_constant",
"click_view",
"customer",
"display_keyword_view",
"dynamic_search_ads_search_term_view",
"expanded_landing_page_view",
+ "feed",
"feed_item",
"feed_item_target",
"feed_placeholder_view",
@@ -35,10 +38,17 @@
"keyword_view",
"label",
"landing_page_view",
+ "language_constant",
"managed_placement_view",
+ "mobile_app_category_constant",
+ "mobile_device_constant",
+ "operating_system_version_constant",
"search_term_view",
"shopping_performance_view",
+ "topic_constant",
"topic_view",
+ "user_interest",
+ "user_list",
"user_location_view",
"video",
]
@@ -248,6 +258,7 @@ def do_discover(resource_schema):
core_streams = do_discover_streams(initialize_core_streams(resource_schema))
report_streams = do_discover_streams(initialize_reports(resource_schema))
streams = []
+
streams.extend(core_streams)
streams.extend(report_streams)
json.dump({"streams": streams}, sys.stdout, indent=2)
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index 9d7f33b..90be94f 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -2,13 +2,25 @@
ACCESSIBLE_BIDDING_STRATEGY_FIELDS = []
ACCOUNT_FIELDS = []
AD_GROUP_AD_FIELDS = []
+AD_GROUP_CRITERION_FIELDS = []
AD_GROUP_FIELDS = []
BIDDING_STRATEGY_FIELDS = []
CALL_VIEW_FIELDS = []
CAMPAIGN_BUDGET_FIELDS = []
+CAMPAIGN_CRITERION_FIELDS = []
CAMPAIGN_FIELDS = []
CAMPAIGN_LABEL_FIELDS = []
+CARRIER_CONSTANT_FIELDS = []
+FEED_FIELDS = []
+FEED_ITEM_FIELDS = []
LABEL_FIELDS = []
+LANGUAGE_CONSTANT_FIELDS = []
+MOBILE_APP_CATEGORY_CONSTANT_FIELDS = []
+MOBILE_DEVICE_CONSTANT_FIELDS = []
+OPERATING_SYSTEM_VERSION_CONSTANT_FIELDS = []
+TOPIC_CONSTANT_FIELDS = []
+USER_INTEREST_FIELDS = []
+USER_LIST_FIELDS = []
# Report streams
ACCOUNT_PERFORMANCE_REPORT_FIELDS = [
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index f295f70..783691b 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -234,6 +234,7 @@ def format_field_names(self):
if (
resource_name not in {"metrics", "segments"}
and resource_name not in self.google_ads_resource_names
+ and "id" in schema["properties"]
):
self.stream_schema["properties"][resource_name + "_id"] = schema["properties"]["id"]
@@ -289,11 +290,11 @@ def build_stream_metadata(self):
if props["field_details"]["selectable"]:
self.stream_metadata[("properties", field)]["tap-google-ads.api-field-names"].append(full_name)
- def transform_keys(self, obj):
+ def transform_keys(self, json_message):
"""This function does a few things with Google's response for sync queries:
- 1) checks an object's fields to see if they're for the current resource
- 2) if they are, keep the fields in transformed_obj with no modifications
- 3) if they are not, append a foreign key to the transformed_obj using the id value
+ 1) checks a json_message's fields to see if they're for the current resource
+ 2) if they are, keep the fields in transformed_json with no modifications
+ 3) if they are not, append a foreign key to the transformed_message using the id value
4) if the resource is ad_group_ad, pops ad fields up to the ad_group_ad level
We've seen API responses where Google returns `type_` when the
@@ -301,24 +302,24 @@ def transform_keys(self, obj):
`"type_": X` to `"type": X`
"""
target_resource_name = self.google_ads_resource_names[0]
- transformed_obj = {}
+ transformed_message = {}
- for resource_name, value in obj.items():
+ for resource_name, value in json_message.items():
resource_matches = target_resource_name == resource_name
if resource_matches:
- transformed_obj.update(value)
+ transformed_message.update(value)
else:
- transformed_obj[f"{resource_name}_id"] = value["id"]
+ transformed_message[f"{resource_name}_id"] = value["id"]
if resource_name == "ad_group_ad":
- transformed_obj.update(value["ad"])
- transformed_obj.pop("ad")
+ transformed_message.update(value["ad"])
+ transformed_message.pop("ad")
- if "type_" in transformed_obj:
- transformed_obj["type"] = transformed_obj.pop("type_")
+ if "type_" in transformed_message:
+ transformed_message["type"] = transformed_message.pop("type_")
- return transformed_obj
+ return transformed_message
def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=unused-argument
gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
@@ -340,8 +341,8 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
# Pages are fetched automatically while iterating through the response
for message in response:
json_message = google_message_to_json(message)
- transformed_obj = self.transform_keys(json_message)
- record = transformer.transform(transformed_obj, stream["schema"], singer.metadata.to_map(stream_mdata))
+ transformed_message = self.transform_keys(json_message)
+ record = transformer.transform(transformed_message, stream["schema"], singer.metadata.to_map(stream_mdata))
singer.write_record(stream_name, record)
@@ -358,6 +359,76 @@ def get_query_date(start_date, bookmark, conversion_window_date):
return singer.utils.strptime_to_utc(query_date)
+class UserInterestStream(BaseStream):
+ """
+ user_interest stream has `user_interest.user_interest_id` instead of a `user_interest.id`
+ this class sets it to id for the user_interest core stream
+ """
+ def format_field_names(self):
+
+ schema = self.full_schema["properties"]["user_interest"]
+ self.stream_schema["properties"]["id"] = schema["properties"]["user_interest_id"]
+ self.stream_schema["properties"].pop("user_interest_id")
+
+ def build_stream_metadata(self):
+ self.stream_metadata = {
+ (): {
+ "inclusion": "available",
+ "forced-replication-method": "FULL_TABLE",
+ "table-key-properties": self.primary_keys,
+ }
+ }
+
+ for field, props in self.resource_fields.items():
+
+ field = field.split(".")[1]
+ if field == "user_interest_id":
+ field = "id"
+
+ if ("properties", field) not in self.stream_metadata:
+ # Base metadata for every field
+ self.stream_metadata[("properties", field)] = {
+ "fieldExclusions": props["incompatible_fields"],
+ "behavior": props["field_details"]["category"],
+ }
+
+ # Add inclusion metadata
+ # Foreign keys are automatically included and they are all id fields
+ if field in self.primary_keys or field in self.automatic_keys:
+ inclusion = "automatic"
+ elif props["field_details"]["selectable"]:
+ inclusion = "available"
+ else:
+ # inclusion = "unsupported"
+ continue
+ self.stream_metadata[("properties", field)]["inclusion"] = inclusion
+
+ # Save the full field name for sync code to use
+ full_name = props["field_details"]["name"]
+ if "tap-google-ads.api-field-names" not in self.stream_metadata[("properties", field)]:
+ self.stream_metadata[("properties", field)]["tap-google-ads.api-field-names"] = []
+
+ if props["field_details"]["selectable"]:
+ self.stream_metadata[("properties", field)]["tap-google-ads.api-field-names"].append(full_name)
+
+ def transform_keys(self, json_message):
+ """
+ This function does a few things with Google's response for sync queries for the user_interest stream:
+ 1) clone json_message to transformed_message
+ 2) create id field with user_interest_id's value
+ 3) pop user_interest_id field off the message
+
+ """
+ transformed_message = {}
+ resource_message = json_message[self.google_ads_resource_names[0]]
+
+ transformed_message.update(resource_message)
+ transformed_message["id"] = resource_message["user_interest_id"]
+ transformed_message.pop("user_interest_id")
+
+ return transformed_message
+
+
class ReportStream(BaseStream):
def create_full_schema(self, resource_schema):
@@ -448,28 +519,28 @@ def build_stream_metadata(self):
self.stream_metadata[("properties", transformed_field_name)]["tap-google-ads.api-field-names"].append(report_field)
- def transform_keys(self, obj):
- transformed_obj = {}
+ def transform_keys(self, json_message):
+ transformed_message = {}
- for resource_name, value in obj.items():
+ for resource_name, value in json_message.items():
if resource_name in {"metrics", "segments"}:
- transformed_obj.update(value)
+ transformed_message.update(value)
elif resource_name == "ad_group_ad":
for key, sub_value in value.items():
if key == 'ad':
- transformed_obj.update(sub_value)
+ transformed_message.update(sub_value)
else:
- transformed_obj.update({f"{resource_name}_{key}": sub_value})
+ transformed_message.update({f"{resource_name}_{key}": sub_value})
else:
# value = {"a": 1, "b":2}
# turns into
# {"resource_a": 1, "resource_b": 2}
- transformed_obj.update(
+ transformed_message.update(
{f"{resource_name}_{key}": sub_value
for key, sub_value in value.items()}
)
- return transformed_obj
+ return transformed_message
def sync(self, sdk_client, customer, stream, config, state):
gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
@@ -527,8 +598,8 @@ def sync(self, sdk_client, customer, stream, config, state):
# Pages are fetched automatically while iterating through the response
for message in response:
json_message = google_message_to_json(message)
- transformed_obj = self.transform_keys(json_message)
- record = transformer.transform(transformed_obj, stream["schema"])
+ transformed_message = self.transform_keys(json_message)
+ record = transformer.transform(transformed_message, stream["schema"])
record["_sdc_record_hash"] = generate_hash(record, stream_mdata)
singer.write_record(stream_name, record)
@@ -566,6 +637,16 @@ def initialize_core_streams(resource_schema):
"customer_id",
},
),
+ "ad_group_criterion": BaseStream(
+ report_definitions.AD_GROUP_CRITERION_FIELDS,
+ ["ad_group_criterion"],
+ resource_schema,
+ ["ad_group_id","criterion_id"],
+ {
+ "campaign_id",
+ "customer_id",
+ },
+ ),
"ads": BaseStream(
report_definitions.AD_GROUP_AD_FIELDS,
["ad_group_ad"],
@@ -609,6 +690,13 @@ def initialize_core_streams(resource_schema):
["id"],
{"customer_id"},
),
+ "campaign_criterion": BaseStream(
+ report_definitions.CAMPAIGN_CRITERION_FIELDS,
+ ["campaign_criterion"],
+ resource_schema,
+ ["campaign_id","criterion_id"],
+ {"customer_id"},
+ ),
"campaign_labels": BaseStream(
report_definitions.CAMPAIGN_LABEL_FIELDS,
["campaign_label"],
@@ -620,6 +708,29 @@ def initialize_core_streams(resource_schema):
"label_id",
},
),
+ "carrier_constant": BaseStream(
+ report_definitions.CARRIER_CONSTANT_FIELDS,
+ ["carrier_constant"],
+ resource_schema,
+ ["id"],
+ ),
+ "feed": BaseStream(
+ report_definitions.FEED_FIELDS,
+ ["feed"],
+ resource_schema,
+ ["id"],
+ {"customer_id"},
+ ),
+ "feed_item": BaseStream(
+ report_definitions.FEED_ITEM_FIELDS,
+ ["feed_item"],
+ resource_schema,
+ ["id"],
+ {
+ "customer_id",
+ "feed_id",
+ },
+ ),
"labels": BaseStream(
report_definitions.LABEL_FIELDS,
["label"],
@@ -627,6 +738,49 @@ def initialize_core_streams(resource_schema):
["id"],
{"customer_id"},
),
+ "language_constant": BaseStream(
+ report_definitions.LANGUAGE_CONSTANT_FIELDS,
+ ["language_constant"],
+ resource_schema,
+ ["id"],
+ ),
+ "mobile_app_category_constant": BaseStream(
+ report_definitions.MOBILE_APP_CATEGORY_CONSTANT_FIELDS,
+ ["mobile_app_category_constant"],
+ resource_schema,
+ ["id"],
+ ),
+ "mobile_device_constant": BaseStream(
+ report_definitions.MOBILE_DEVICE_CONSTANT_FIELDS,
+ ["mobile_device_constant"],
+ resource_schema,
+ ["id"],
+ ),
+ "operating_system_version_constant": BaseStream(
+ report_definitions.OPERATING_SYSTEM_VERSION_CONSTANT_FIELDS,
+ ["operating_system_version_constant"],
+ resource_schema,
+ ["id"],
+ ),
+ "topic_constant": BaseStream(
+ report_definitions.TOPIC_CONSTANT_FIELDS,
+ ["topic_constant"],
+ resource_schema,
+ ["id"],
+ ),
+ "user_interest": UserInterestStream(
+ report_definitions.USER_INTEREST_FIELDS,
+ ["user_interest"],
+ resource_schema,
+ ["id"],
+ ),
+ "user_list": BaseStream(
+ report_definitions.USER_LIST_FIELDS,
+ ["user_list"],
+ resource_schema,
+ ["id"],
+ {"customer_id"},
+ ),
}
diff --git a/tests/base.py b/tests/base.py
index 1194fa2..03ed2ae 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -80,7 +80,7 @@ def expected_metadata(self):
"""
return {
# Core Objects
- 'accessible_bidding_strategies': {
+ "accessible_bidding_strategies": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.AUTOMATIC_KEYS: {"customer_id"},
@@ -94,26 +94,33 @@ def expected_metadata(self):
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.AUTOMATIC_KEYS: {
- 'campaign_id',
- 'customer_id',
+ "campaign_id",
+ "customer_id",
+ },
+ },
+ "ad_group_criterion": {
+ self.PRIMARY_KEYS: {"ad_group_id", "criterion_id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: {
+ "campaign_id",
+ "customer_id",
},
-
},
"ads": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.AUTOMATIC_KEYS: {
+ "ad_group_id",
"campaign_id",
"customer_id",
- "ad_group_id"
},
},
- 'bidding_strategies': {
+ "bidding_strategies": {
self.PRIMARY_KEYS:{"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.AUTOMATIC_KEYS: {"customer_id"},
},
- 'call_details': {
+ "call_details": {
self.PRIMARY_KEYS: {"resource_name"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.AUTOMATIC_KEYS: {
@@ -125,18 +132,19 @@ def expected_metadata(self):
"campaigns": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.AUTOMATIC_KEYS: {
- 'customer_id'
- },
+ self.AUTOMATIC_KEYS: {"customer_id"},
},
- 'campaign_budgets': {
+ "campaign_budgets": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
- self.AUTOMATIC_KEYS: {
- "customer_id"
- },
+ self.AUTOMATIC_KEYS: {"customer_id"},
+ },
+ "campaign_criterion": {
+ self.PRIMARY_KEYS: {"campaign_id", "criterion_id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: {"customer_id"},
},
- 'campaign_labels': {
+ "campaign_labels": {
self.PRIMARY_KEYS: {"resource_name"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.AUTOMATIC_KEYS: {
@@ -145,12 +153,63 @@ def expected_metadata(self):
"label_id"
},
},
- 'labels': {
+ "carrier_constant": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: set(),
+ },
+ "feed": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: {"customer_id"},
+ },
+ "feed_item": {
self.PRIMARY_KEYS: {"id"},
self.REPLICATION_METHOD: self.FULL_TABLE,
self.AUTOMATIC_KEYS: {
"customer_id",
- },
+ "feed_id",
+ },
+ },
+ "labels": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: {"customer_id"},
+ },
+ "language_constant": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: set(),
+ },
+ "mobile_app_category_constant": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: set(),
+ },
+ "mobile_device_constant": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: set(),
+ },
+ "operating_system_version_constant": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: set(),
+ },
+ "topic_constant": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: set(),
+ },
+ "user_interest": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: set(),
+ },
+ "user_list": {
+ self.PRIMARY_KEYS: {"id"},
+ self.REPLICATION_METHOD: self.FULL_TABLE,
+ self.AUTOMATIC_KEYS: {"customer_id"},
},
# Report objects
diff --git a/tests/test_google_ads_automatic_fields.py b/tests/test_google_ads_automatic_fields.py
index 171a442..942cee0 100644
--- a/tests/test_google_ads_automatic_fields.py
+++ b/tests/test_google_ads_automatic_fields.py
@@ -100,7 +100,7 @@ def test_happy_path(self):
# Perform table and field selection...
catalogs_to_test = [catalog for catalog in found_catalogs
if catalog['stream_name'] in streams_to_test]
- # select all fields for core streams and...
+ # select no fields for streams and rely on automatic metadata to ensure minimum selection
self.select_all_streams_and_fields(conn_id, catalogs_to_test, select_all_fields=False)
# Run a sync
@@ -111,25 +111,31 @@ def test_happy_path(self):
menagerie.verify_sync_exit_status(self, exit_status, sync_job_name)
# acquire records from target output
- synced_records = runner.get_records_from_target_output()
+ synced_messages = runner.get_records_from_target_output()
for stream in streams_to_test:
with self.subTest(stream=stream):
- # # Verify that only the automatic fields are sent to the target.
+ # gather expectations
+ expected_primary_keys = list(self.expected_primary_keys()[stream])
expected_auto_fields = self.expected_automatic_fields()
- expected_primary_key = list(self.expected_primary_keys()[stream])[0] # assumes no compound-pks
- self.assertEqual(len(self.expected_primary_keys()[stream]), 1, msg="Compound pk not supported")
- for record in synced_records[stream]['messages']:
- record_primary_key_values = record['data'][expected_primary_key]
- record_keys = set(record['data'].keys())
+ # gather results
+ synced_records = [message for message in
+ synced_messages.get(stream, {'messages':[]}).get('messages', [])
+ if message['action'] == 'upsert']
+ actual_primary_key_values = [tuple([record.get('data').get(expected_pk)
+ for expected_pk in expected_primary_keys])
+ for record in synced_records]
- with self.subTest(primary_key=record_primary_key_values):
- self.assertSetEqual(expected_auto_fields[stream], record_keys)
+ # Verify some record messages were synced
+ self.assertGreater(len(synced_records), 0)
# Verify that all replicated records have unique primary key values.
- actual_pks = [row.get('data').get(expected_primary_key) for row in
- synced_records.get(stream, {'messages':[]}).get('messages', []) if row.get('data')]
+ self.assertCountEqual(actual_primary_key_values, set(actual_primary_key_values))
- self.assertCountEqual(actual_pks, set(actual_pks))
+ # Verify that only the automatic fields are sent in records
+ for record in synced_records:
+ with self.subTest(record=record['data']):
+ record_keys = set(record['data'].keys())
+ self.assertSetEqual(expected_auto_fields[stream], record_keys)
diff --git a/tests/test_google_ads_conversion_window.py b/tests/test_google_ads_conversion_window.py
index 080be26..6079db5 100644
--- a/tests/test_google_ads_conversion_window.py
+++ b/tests/test_google_ads_conversion_window.py
@@ -47,7 +47,7 @@ def run_test(self):
print("Configurable Properties Test (conversion_window)")
streams_to_test = {
- 'campagins',
+ 'campaigns',
'account_performance_report',
}
diff --git a/tests/test_google_ads_conversion_window_invalid.py b/tests/test_google_ads_conversion_window_invalid.py
index e66edc8..f26f5cb 100644
--- a/tests/test_google_ads_conversion_window_invalid.py
+++ b/tests/test_google_ads_conversion_window_invalid.py
@@ -48,7 +48,7 @@ def run_test(self):
print("Configurable Properties Test (conversion_window)")
streams_to_test = {
- 'campagins',
+ 'campaigns',
'account_performance_report',
}
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index 725a33c..e5eef3b 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -76,17 +76,18 @@ def test_run(self):
streams_to_test = self.expected_streams() - {
# no test data available, but can generate
- 'display_keyword_performance_report', # Singer Display #2, Ad Group 2
- 'display_topics_performance_report', # Singer Display #2, Ad Group 2
+ "call_details", # need test call data before data will be returned
+ "display_keyword_performance_report", # Singer Display #2, Ad Group 2
+ "display_topics_performance_report", # Singer Display #2, Ad Group 2
"keywords_performance_report", # needs a Search Campaign (currently have none)
# audiences are unclear on how metrics fall into segments
- 'campaign_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
- 'ad_group_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ "campaign_audience_performance_report", # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ "ad_group_audience_performance_report", # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
# cannot generate test data
- 'placement_performance_report', # need an app to run javascript to trace conversions
+ "placement_performance_report", # need an app to run javascript to trace conversions
"video_performance_report", # need a video to show
"shopping_performance_report", # need Shopping campaign type, and link to a store
- "call_details", # need test call data before data will be returned
+
}
# Run a discovery job
From f55f47fcc8416a18f8da1d1f54326c30074b578a Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Wed, 11 May 2022 11:00:05 -0400
Subject: [PATCH 49/69] V1.3.0 (#59)
* Version bump for PRs 56 and 55
* Update to exclude ad_group_ad change for ad_performance_report
* Version bump for 1.3.0
Co-authored-by: Arthur Gorka
---
CHANGELOG.md | 5 +++++
setup.py | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23c6cb2..c9aa24b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v1.3.0 [#58](https://github.com/singer-io/tap-google-ads/pull/58)
+ * Adds several new core streams including ad_group_criterion, campaign_criterion, and their attributed resources.
+ * Adds new subclass UserInterestStream to handle stream specific name transformations.
+ * Renames obj and corresponding variables in all transform_keys functions.
+
## v1.2.0
* Renames `REPORTS` variable to `STREAMS` and updates corresponding variables similarly. Removes unused `add_extra_fields` function [#56](https://github.com/singer-io/tap-google-ads/pull/56)
* Adds `automatic_keys` to metadata for streams, including reports. Updates tests [#55](https://github.com/singer-io/tap-google-ads/pull/55)
diff --git a/setup.py b/setup.py
index 6ea65d0..3302101 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.2.0',
+ version='1.3.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From a9ca5583da48a708de4e78ee5803497d9e2800c6 Mon Sep 17 00:00:00 2001
From: Kyle Speer <54034650+kspeer825@users.noreply.github.com>
Date: Wed, 15 Jun 2022 13:41:33 -0400
Subject: [PATCH 50/69] Qa/fix build notification (#65)
* fix slack notif for build
* remove click_performance_report from tests
* remove click_performance_report from sync canary test
* run only streams that are untested in canary sync
* just skip canaray test
* put back the assert in the skipped test
Co-authored-by: kspeer
---
.circleci/config.yml | 8 ++++----
tests/test_google_ads_automatic_fields.py | 11 ++++++-----
tests/test_google_ads_bookmarks.py | 6 +++---
tests/test_google_ads_start_date.py | 13 +++++++------
tests/test_google_ads_sync_canary.py | 21 ++++++++++++---------
5 files changed, 32 insertions(+), 27 deletions(-)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 2a5b437..044ebc8 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -25,7 +25,7 @@ jobs:
pip install 'setuptools==56.0.0'
pip install .[dev]
- slack/notify-on-failure:
- only_for_branches: master
+ only_for_branches: main
- persist_to_workspace:
root: /usr/local/share/virtualenvs
paths:
@@ -45,7 +45,7 @@ jobs:
echo "$PYLINT_DISABLE_LIST"
pylint tap_google_ads --disable "$PYLINT_DISABLE_LIST"
- slack/notify-on-failure:
- only_for_branches: master
+ only_for_branches: main
run_unit_tests:
executor: docker-executor
@@ -65,7 +65,7 @@ jobs:
- store_artifacts:
path: htmlcov
- slack/notify-on-failure:
- only_for_branches: master
+ only_for_branches: main
run_integration_tests:
executor: docker-executor
@@ -89,7 +89,7 @@ jobs:
done
fi
- slack/notify-on-failure:
- only_for_branches: master
+ only_for_branches: main
workflows:
version: 2
diff --git a/tests/test_google_ads_automatic_fields.py b/tests/test_google_ads_automatic_fields.py
index 942cee0..6303403 100644
--- a/tests/test_google_ads_automatic_fields.py
+++ b/tests/test_google_ads_automatic_fields.py
@@ -80,18 +80,19 @@ def test_happy_path(self):
streams_to_test = {stream for stream in self.expected_streams()
if stream not in {
# no test data available, but can generate
+ "keywords_performance_report", # needs a Search Campaign (currently have none)
+ 'click_performance_report', # only last 90 days returned
'display_keyword_performance_report', # Singer Display #2, Ad Group 2
'display_topics_performance_report', # Singer Display #2, Ad Group 2
- "keywords_performance_report", # needs a Search Campaign (currently have none)
# audiences are unclear on how metrics fall into segments
- 'campaign_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
'ad_group_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ 'campaign_audience_performance_report', # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
# cannot generate test data
- 'placement_performance_report', # need an app to run javascript to trace conversions
- "video_performance_report", # need a video to show
- "shopping_performance_report", # need Shopping campaign type, and link to a store
"call_details", # need test call data before data will be returned
+ "shopping_performance_report", # need Shopping campaign type, and link to a store
"shopping_performance_report", # No automatic keys for this report
+ "video_performance_report", # need a video to show
+ 'placement_performance_report', # need an app to run javascript to trace conversions
}}
# Run a discovery job
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index 370c0e2..945faeb 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -46,6 +46,9 @@ def test_run(self):
streams_under_test = self.expected_streams() - {
'ad_group_audience_performance_report',
+ 'call_details', # need test call data before data will be returned
+ 'campaign_audience_performance_report',
+ 'click_performance_report', # only last 90 days returned
'display_keyword_performance_report',
'display_topics_performance_report',
'keywords_performance_report',
@@ -53,8 +56,6 @@ def test_run(self):
'search_query_performance_report',
'shopping_performance_report',
'video_performance_report',
- 'campaign_audience_performance_report',
- 'call_details', # need test call data before data will be returned
}
# Run a discovery job
@@ -93,7 +94,6 @@ def test_run(self):
'placeholder_feed_item_report': data_set_state_value_2,
'age_range_performance_report': data_set_state_value_1,
'account_performance_report': data_set_state_value_1,
- 'click_performance_report': data_set_state_value_1,
'campaign_performance_report': data_set_state_value_1,
'placeholder_report': data_set_state_value_2,
'ad_performance_report': data_set_state_value_1,
diff --git a/tests/test_google_ads_start_date.py b/tests/test_google_ads_start_date.py
index 18e189d..162620f 100644
--- a/tests/test_google_ads_start_date.py
+++ b/tests/test_google_ads_start_date.py
@@ -148,15 +148,16 @@ def run_test(self):
class StartDateTest1(StartDateTest):
missing_coverage_streams = { # no test data available
+ 'ad_group_audience_performance_report',
+ 'call_details',
+ 'campaign_audience_performance_report',
+ 'click_performance_report', # only last 90 days returned
'display_keyword_performance_report',
'display_topics_performance_report',
+ 'keywords_performance_report',
'placement_performance_report',
- "keywords_performance_report",
- "video_performance_report",
- 'ad_group_audience_performance_report',
- "shopping_performance_report",
- 'campaign_audience_performance_report',
- 'call_details',
+ 'shopping_performance_report',
+ 'video_performance_report',
}
def setUp(self):
diff --git a/tests/test_google_ads_sync_canary.py b/tests/test_google_ads_sync_canary.py
index e5eef3b..16ceb99 100644
--- a/tests/test_google_ads_sync_canary.py
+++ b/tests/test_google_ads_sync_canary.py
@@ -1,6 +1,8 @@
import re
+import unittest
from tap_tester import menagerie, connections, runner
+from tap_tester.logger import LOGGER
from base import GoogleAdsBase
@@ -15,9 +17,11 @@ class SyncCanaryTest(GoogleAdsBase):
def name():
return "tt_google_ads_canary"
+ @unittest.skip("USED FOR MANUAL VERIFICATION OF TEST DATA ONLY")
def test_run(self):
"""
- Testing that basic sync functions without Critical Errors
+ Testing that basic sync functions without Critical Errors for streams without test data
+ that are not covered in other tests.
Test Data available for the following report streams across the following dates (only the
first and last date that data was generated is listed).
@@ -70,24 +74,24 @@ def test_run(self):
"2021-12-06T00:00:00.000000Z"
"2022-03-14T00:00:00.000000Z"
"""
- print("Canary Sync Test for tap-google-ads")
+ LOGGER.info("Canary Sync Test for tap-google-ads")
conn_id = connections.ensure_connection(self)
- streams_to_test = self.expected_streams() - {
+ streams_to_test = - self.expected_streams() - {
# no test data available, but can generate
"call_details", # need test call data before data will be returned
+ "click_performance_report", # only last 90 days returned
"display_keyword_performance_report", # Singer Display #2, Ad Group 2
"display_topics_performance_report", # Singer Display #2, Ad Group 2
"keywords_performance_report", # needs a Search Campaign (currently have none)
# audiences are unclear on how metrics fall into segments
- "campaign_audience_performance_report", # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
"ad_group_audience_performance_report", # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
+ "campaign_audience_performance_report", # Singer Display #2/Singer Display, Ad Group 2 (maybe?)
# cannot generate test data
"placement_performance_report", # need an app to run javascript to trace conversions
- "video_performance_report", # need a video to show
"shopping_performance_report", # need Shopping campaign type, and link to a store
-
+ "video_performance_report", # need a video to show
}
# Run a discovery job
@@ -118,8 +122,7 @@ def test_run(self):
# Verify at least 1 record was replicated for each stream
for stream in streams_to_test:
-
with self.subTest(stream=stream):
- record_count = len(synced_records.get(stream, {'messages': []})['messages'])
self.assertGreater(record_count, 0)
- print(f"{record_count} {stream} record(s) replicated.")
+ record_count = len(synced_records.get(stream, {'messages': []})['messages'])
+ LOGGER.info(f"{record_count} {stream} record(s) replicated.")
From ba39b455b6286e058022e6942ba4b5cb22a2295b Mon Sep 17 00:00:00 2001
From: Prijen Khokhani <88327452+prijendev@users.noreply.github.com>
Date: Thu, 16 Jun 2022 01:32:38 +0530
Subject: [PATCH 51/69] Crest master (#66)
* Tdl 19235 handle uncaught exceptions (#61)
* Added backoff for 5xx, 429 and ReadTimeout errors.
* Resolved pylint error.
* Updated comments in the unittest cases.
* Updated error handling.
* TDL-18749 Implement interruptible full table streams. (#60)
* Implemented interruptible full table streams.
* Resolved pylint error
* Resolved error in full table sync test case.
* Updated config.yml to pass cci
* Updated query building logic.
* Updated integration test case.
* Resolved review comments.
* Resolved comments.
* Implemeted logic to skip the duplicate records.
* Resolved unittest case error.
* Resolved pylint error
* Resolved integration test case error
* Added empty filter param for call_details and campaign_label stream.
* Added unit test cases for should_sync method.
* Revert "Implemeted logic to skip the duplicate records."
This reverts commit cd06e11657bd35edbaefcd7f8f12acfb938e05ec.
* Added logger message for debugging purpose
* Updated integration test case.
* Replaced .format with f string.
* Updated comment in integration test.
Co-authored-by: KrishnanG
---
.circleci/config.yml | 2 +-
tap_google_ads/streams.py | 133 ++++++++++++++++--
..._google_ads_interrupted_sync_full_table.py | 132 +++++++++++++++++
tests/unittests/test_backoff.py | 113 +++++++++++++++
.../test_core_stream_query_building.py | 70 +++++++++
5 files changed, 435 insertions(+), 15 deletions(-)
create mode 100644 tests/test_google_ads_interrupted_sync_full_table.py
create mode 100644 tests/unittests/test_backoff.py
create mode 100644 tests/unittests/test_core_stream_query_building.py
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 044ebc8..ff7e698 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -69,7 +69,7 @@ jobs:
run_integration_tests:
executor: docker-executor
- parallelism: 18
+ parallelism: 19
steps:
- checkout
- attach_workspace:
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 783691b..57cd39e 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -4,9 +4,11 @@
from datetime import timedelta
import singer
from singer import Transformer
-from singer import utils
+from singer import utils, metrics
from google.protobuf.json_format import MessageToJson
from google.ads.googleads.errors import GoogleAdsException
+from google.api_core.exceptions import ServerError, TooManyRequests
+from requests.exceptions import ReadTimeout
import backoff
from . import report_definitions
@@ -25,7 +27,7 @@
)
DEFAULT_CONVERSION_WINDOW = 30
-
+DEFAULT_PAGE_SIZE = 1000
def get_conversion_window(config):
"""Fetch the conversion window from the config and error on invalid values"""
@@ -80,9 +82,60 @@ def build_parameters():
param_str = ",".join(f"{k}={v}" for k, v in API_PARAMETERS.items())
return f"PARAMETERS {param_str}"
+def generate_where_and_orderby_clause(last_pk_fetched, filter_param, composite_pks):
+ """
+ Generates a WHERE clause and a ORDER BY clause based on filter parameter(`key_properties`), and
+ `last_pk_fetched`.
+
+ Example:
+
+ Single PK Case:
+
+ filter_param = 'id'
+ last_pk_fetched = 1
+ composite_pks = False
+ Returns:
+ WHERE id > 1 ORDER BY id ASC
+
+ Composite PK Case:
+
+ composite_pks = True
+ filter_param = 'id'
+ last_pk_fetched = 1
+ Returns:
+ WHERE id >= 1 ORDER BY id ASC
+ """
+ where_clause = ""
+ order_by_clause = ""
+
+ # Even If the stream has a composite primary key, we are storing only a single pk value in the bookmark.
+ # So, there might be possible that records with the same single pk value exist with different pk value combinations.
+ # That's why for composite_pks we are using a greater than or equal operator.
+ comparison_operator = ">="
+
+ if not composite_pks:
+ # Exclude equality for the stream which do not have a composite primary key.
+ # Because in single pk case we are sure that no other record will have the same pk.
+ # So, we do not want to fetch the last record again.
+ comparison_operator = ">"
+
+ if filter_param:
+ # Create ORDER BY clause for the stream which support filter parameter.
+ order_by_clause = f"ORDER BY {filter_param} ASC"
+
+ if last_pk_fetched:
+ # Create WHERE clause based on last_pk_fetched.
+ where_clause = f'WHERE {filter_param} {comparison_operator} {last_pk_fetched} '
+
+ return f'{where_clause}{order_by_clause}'
+
+def create_core_stream_query(resource_name, selected_fields, last_pk_fetched, filter_param, composite_pks):
+
+ # Generate a query using WHERE and ORDER BY parameters.
+ where_order_by_clause = generate_where_and_orderby_clause(last_pk_fetched, filter_param, composite_pks)
+
+ core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} {where_order_by_clause} {build_parameters()}"
-def create_core_stream_query(resource_name, selected_fields):
- core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} {build_parameters()}"
return core_query
@@ -117,6 +170,13 @@ def generate_hash(record, metadata):
def should_give_up(ex):
+
+ # ServerError is the parent class of InternalServerError, MethodNotImplemented, BadGateway,
+ # ServiceUnavailable, GatewayTimeout, DataLoss and Unknown classes.
+ # Return False for all above errors and ReadTimeout error.
+ if isinstance(ex, (ServerError, TooManyRequests, ReadTimeout)):
+ return False
+
if isinstance(ex, AttributeError):
if str(ex) == "'NoneType' object has no attribute 'Call'":
LOGGER.info('Retrying request due to AttributeError')
@@ -141,12 +201,14 @@ def on_giveup_func(err):
@backoff.on_exception(backoff.expo,
(GoogleAdsException,
+ ServerError, TooManyRequests,
+ ReadTimeout,
AttributeError),
max_tries=5,
jitter=None,
giveup=should_give_up,
on_giveup=on_giveup_func,
- logger=None)
+ )
def make_request(gas, query, customer_id):
response = gas.search(query=query, customer_id=customer_id)
return response
@@ -172,11 +234,12 @@ def filter_out_non_attribute_fields(fields):
class BaseStream: # pylint: disable=too-many-instance-attributes
- def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys, automatic_keys = None):
+ def __init__(self, fields, google_ads_resource_names, resource_schema, primary_keys, automatic_keys = None, filter_param = None):
self.fields = fields
self.google_ads_resource_names = google_ads_resource_names
self.primary_keys = primary_keys
self.automatic_keys = automatic_keys if automatic_keys else set()
+ self.filter_param = filter_param
self.extract_field_information(resource_schema)
self.create_full_schema(resource_schema)
@@ -330,22 +393,44 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
state = singer.set_currently_syncing(state, [stream_name, customer["customerId"]])
singer.write_state(state)
- query = create_core_stream_query(resource_name, selected_fields)
+ # last run was interrupted if there is a bookmark available for core streams.
+ last_pk_fetched = singer.get_bookmark(state,
+ stream["tap_stream_id"],
+ customer["customerId"]) or {}
+
+ # Assign True if the primary key is composite.
+ composite_pks = len(self.primary_keys) > 1
+
+ query = create_core_stream_query(resource_name, selected_fields, last_pk_fetched.get('last_pk_fetched'), self.filter_param, composite_pks)
try:
response = make_request(gas, query, customer["customerId"])
except GoogleAdsException as err:
LOGGER.warning("Failed query: %s", query)
raise err
- with Transformer() as transformer:
- # Pages are fetched automatically while iterating through the response
- for message in response:
- json_message = google_message_to_json(message)
- transformed_message = self.transform_keys(json_message)
- record = transformer.transform(transformed_message, stream["schema"], singer.metadata.to_map(stream_mdata))
+ with metrics.record_counter(stream_name) as counter:
+ with Transformer() as transformer:
+ # Pages are fetched automatically while iterating through the response
+ for message in response:
+ json_message = google_message_to_json(message)
+ transformed_message = self.transform_keys(json_message)
+ record = transformer.transform(transformed_message, stream["schema"], singer.metadata.to_map(stream_mdata))
+
+ singer.write_record(stream_name, record)
+ counter.increment()
+
+ # Write state(last_pk_fetched) using primary key(id) value for core streams after DEFAULT_PAGE_SIZE records
+ if counter.value % DEFAULT_PAGE_SIZE == 0 and self.filter_param:
+ bookmark_value = record[self.primary_keys[0]]
+ singer.write_bookmark(state, stream["tap_stream_id"], customer["customerId"], {'last_pk_fetched': bookmark_value})
- singer.write_record(stream_name, record)
+ singer.write_state(state)
+ LOGGER.info("Write state for stream: %s, value: %s", stream_name, bookmark_value)
+ # Flush the state for core streams if sync is completed
+ if stream["tap_stream_id"] in state.get('bookmarks', {}):
+ state['bookmarks'].pop(stream["tap_stream_id"])
+ singer.write_state(state)
def get_query_date(start_date, bookmark, conversion_window_date):
"""Return a date within the conversion window and after start date
@@ -620,12 +705,14 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
{"customer_id"},
+ filter_param="accessible_bidding_strategy.id"
),
"accounts": BaseStream(
report_definitions.ACCOUNT_FIELDS,
["customer"],
resource_schema,
["id"],
+ filter_param="customer.id"
),
"ad_groups": BaseStream(
report_definitions.AD_GROUP_FIELDS,
@@ -636,6 +723,7 @@ def initialize_core_streams(resource_schema):
"campaign_id",
"customer_id",
},
+ filter_param="ad_group.id"
),
"ad_group_criterion": BaseStream(
report_definitions.AD_GROUP_CRITERION_FIELDS,
@@ -646,6 +734,7 @@ def initialize_core_streams(resource_schema):
"campaign_id",
"customer_id",
},
+ filter_param="ad_group.id"
),
"ads": BaseStream(
report_definitions.AD_GROUP_AD_FIELDS,
@@ -657,6 +746,7 @@ def initialize_core_streams(resource_schema):
"campaign_id",
"customer_id",
},
+ filter_param = "ad_group_ad.ad.id"
),
"bidding_strategies": BaseStream(
report_definitions.BIDDING_STRATEGY_FIELDS,
@@ -664,6 +754,7 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
{"customer_id"},
+ filter_param="bidding_strategy.id"
),
"call_details": BaseStream(
report_definitions.CALL_VIEW_FIELDS,
@@ -682,6 +773,7 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
{"customer_id"},
+ filter_param="campaign.id"
),
"campaign_budgets": BaseStream(
report_definitions.CAMPAIGN_BUDGET_FIELDS,
@@ -689,6 +781,7 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
{"customer_id"},
+ filter_param="campaign_budget.id"
),
"campaign_criterion": BaseStream(
report_definitions.CAMPAIGN_CRITERION_FIELDS,
@@ -696,6 +789,7 @@ def initialize_core_streams(resource_schema):
resource_schema,
["campaign_id","criterion_id"],
{"customer_id"},
+ filter_param="campaign.id"
),
"campaign_labels": BaseStream(
report_definitions.CAMPAIGN_LABEL_FIELDS,
@@ -713,6 +807,7 @@ def initialize_core_streams(resource_schema):
["carrier_constant"],
resource_schema,
["id"],
+ filter_param="carrier_constant.id"
),
"feed": BaseStream(
report_definitions.FEED_FIELDS,
@@ -720,6 +815,7 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
{"customer_id"},
+ filter_param="feed.id"
),
"feed_item": BaseStream(
report_definitions.FEED_ITEM_FIELDS,
@@ -730,6 +826,7 @@ def initialize_core_streams(resource_schema):
"customer_id",
"feed_id",
},
+ filter_param="feed_item.id"
),
"labels": BaseStream(
report_definitions.LABEL_FIELDS,
@@ -737,42 +834,49 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
{"customer_id"},
+ filter_param="label.id"
),
"language_constant": BaseStream(
report_definitions.LANGUAGE_CONSTANT_FIELDS,
["language_constant"],
resource_schema,
["id"],
+ filter_param="language_constant.id"
),
"mobile_app_category_constant": BaseStream(
report_definitions.MOBILE_APP_CATEGORY_CONSTANT_FIELDS,
["mobile_app_category_constant"],
resource_schema,
["id"],
+ filter_param="mobile_app_category_constant.id"
),
"mobile_device_constant": BaseStream(
report_definitions.MOBILE_DEVICE_CONSTANT_FIELDS,
["mobile_device_constant"],
resource_schema,
["id"],
+ filter_param="mobile_device_constant.id"
),
"operating_system_version_constant": BaseStream(
report_definitions.OPERATING_SYSTEM_VERSION_CONSTANT_FIELDS,
["operating_system_version_constant"],
resource_schema,
["id"],
+ filter_param="operating_system_version_constant.id"
),
"topic_constant": BaseStream(
report_definitions.TOPIC_CONSTANT_FIELDS,
["topic_constant"],
resource_schema,
["id"],
+ filter_param="topic_constant.id"
),
"user_interest": UserInterestStream(
report_definitions.USER_INTEREST_FIELDS,
["user_interest"],
resource_schema,
["id"],
+ filter_param="user_interest.user_interest_id"
),
"user_list": BaseStream(
report_definitions.USER_LIST_FIELDS,
@@ -780,6 +884,7 @@ def initialize_core_streams(resource_schema):
resource_schema,
["id"],
{"customer_id"},
+ filter_param="user_list.id"
),
}
diff --git a/tests/test_google_ads_interrupted_sync_full_table.py b/tests/test_google_ads_interrupted_sync_full_table.py
new file mode 100644
index 0000000..5419b7c
--- /dev/null
+++ b/tests/test_google_ads_interrupted_sync_full_table.py
@@ -0,0 +1,132 @@
+from asyncio import streams
+import os
+
+from tap_tester import menagerie, connections, runner
+
+from base import GoogleAdsBase
+
+
+class InterruptedSyncFullTableTest(GoogleAdsBase):
+ """Test tap's ability to recover from an interrupted sync for FULL Table stream"""
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_interruption_full_table"
+
+ def test_run(self):
+
+ """
+ Scenario: A sync job is interrupted for full table stream. The state is saved with `currently_syncing`
+ and `last_pk_fetched`(id of last synced record).
+ The next sync job kicks off, the tap picks only remaining records for interrupted stream and complete the sync.
+
+ Expected State Structure:
+ state = {'currently_syncing': ('', ''),
+ 'bookmarks': {
+ '': {'': {last_pk_fetched: }},
+
+ Test Cases:
+ - Verify that id of 1st record in interrupted sync is greater than or equal to last_pk_fetched.
+ - Verify that all records in the full sync and interrupted sync come in Ascending order.
+ - Verify interrupted_sync has the fewer records as compared to full sync
+ - Verify state is flushed if sync is completed.
+ - Verify resuming sync replicates all records for streams that were yet-to-be-synced
+ """
+
+ streams_under_test = {
+ 'ads',
+ 'campaign_criterion',
+ 'feed'
+ }
+
+ # Create connection
+ conn_id = connections.ensure_connection(self)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ # partition catalogs for use in table/field selection
+ test_catalogs = [catalog for catalog in found_catalogs
+ if catalog.get('stream_name') in streams_under_test]
+
+ # select fields
+ self.select_all_streams_and_fields(conn_id, test_catalogs, select_all_fields=False)
+
+ # Run a sync
+ self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ full_sync_records = runner.get_records_from_target_output()
+
+
+ # Set state such that first stream has 'completed' a sync. The interrupted stream ('campaign_criterion')
+ # should have a bookmark value prior to the 'completed' streams.
+
+ interrupted_state = {
+ 'currently_syncing': ('campaign_criterion', '5548074409'),
+ 'bookmarks': {
+ 'campaign_criterion': {'5548074409': {'last_pk_fetched': 16990616126}},
+ }
+ }
+
+ # set state for interrupted sync
+ menagerie.set_state(conn_id, interrupted_state)
+
+ # Run another sync
+ self.run_and_verify_sync(conn_id)
+
+ # acquire records from target output
+ interrupted_sync_records = runner.get_records_from_target_output()
+ final_state = menagerie.get_state(conn_id)
+
+ # stream-level assertions
+ for stream in streams_under_test:
+ with self.subTest(stream=stream):
+
+ # gather results
+ full_records = [message['data'] for message in full_sync_records[stream]['messages']]
+ full_record_count = len(full_records)
+ interrupted_records = [message['data'] for message in interrupted_sync_records[stream]['messages']]
+ interrupted_record_count = len(interrupted_records)
+
+ # campaign_criterion stream has a composite primary key.
+ # But, to filter out the records, we are using only campaign_id respectively.
+ if stream == "campaign_criterion":
+ primary_key = "campaign_id"
+ else:
+ primary_key = next(iter(self.expected_primary_keys()[stream]))
+
+ # Verify that all records in the full sync come in Ascending order.
+ # That means id of current record is greater than id of previous record.
+ for i in range(1, full_record_count):
+ self.assertGreaterEqual(full_records[i][primary_key], full_records[i-1][primary_key],
+ msg='id of the current record is less than the id of the previous record.')
+
+ # Verify that all records in the interrupted sync come in Ascending order.
+ # That means id of current record is greater than id of previous record.
+ for i in range(1, interrupted_record_count):
+ self.assertGreaterEqual(interrupted_records[i][primary_key], interrupted_records[i-1][primary_key],
+ msg='id of the current record is less than the id of the previous record.')
+
+ if stream in interrupted_state['bookmarks'].keys():
+
+ # Verify second sync(interrupted_sync) have the less records as compared to first sync(full sync) for interrupted stream
+ self.assertLess(interrupted_record_count, full_record_count)
+
+ # Verify that id of 1st record in interrupted sync is greater than or equal to last_pk_fetched for interrupted stream.
+ self.assertGreaterEqual(interrupted_records[0][primary_key], 16990616126, msg='id of first record in interrupted sync is less than last_pk_fetched')
+
+ else:
+ # Verify resuming sync replicates all records for streams that were yet-to-be-synced
+
+ for record in interrupted_records:
+ with self.subTest(record_primary_key=record[primary_key]):
+ self.assertIn(record, full_records, msg='Unexpected record replicated in resuming sync.')
+
+ for record in full_records:
+ with self.subTest(record_primary_key=record[primary_key]):
+ self.assertIn(record, interrupted_records, msg='Record missing from resuming sync.' )
+
+
+ # Verify state is flushed after sync completed.
+ self.assertNotIn(stream, final_state['bookmarks'].keys())
\ No newline at end of file
diff --git a/tests/unittests/test_backoff.py b/tests/unittests/test_backoff.py
new file mode 100644
index 0000000..eef14fe
--- /dev/null
+++ b/tests/unittests/test_backoff.py
@@ -0,0 +1,113 @@
+import unittest
+from unittest.mock import Mock, patch
+from tap_google_ads.streams import make_request
+from google.api_core.exceptions import InternalServerError, BadGateway, MethodNotImplemented, ServiceUnavailable, GatewayTimeout, TooManyRequests
+from requests.exceptions import ReadTimeout
+
+@patch('time.sleep')
+class TestBackoff(unittest.TestCase):
+
+ def test_500_internal_server_error(self, mock_sleep):
+ """
+ Check whether the tap backoffs properly for 5 times in case of InternalServerError.
+ """
+ mocked_google_ads_client = Mock()
+ mocked_google_ads_client.search.side_effect = InternalServerError("Internal error encountered")
+
+ try:
+ make_request(mocked_google_ads_client, "", "")
+ except InternalServerError:
+ pass
+
+ # Verify that tap backoff for 5 times
+ self.assertEquals(mocked_google_ads_client.search.call_count, 5)
+
+ def test_501_not_implemented_error(self, mock_sleep):
+ """
+ Check whether the tap backoffs properly for 5 times in case of MethodNotImplemented error.
+ """
+ mocked_google_ads_client = Mock()
+ mocked_google_ads_client.search.side_effect = MethodNotImplemented("Not Implemented")
+
+ try:
+ make_request(mocked_google_ads_client, "", "")
+ except MethodNotImplemented:
+ pass
+
+ # Verify that tap backoff for 5 times
+ self.assertEquals(mocked_google_ads_client.search.call_count, 5)
+
+ def test_502_bad_gaetway_error(self, mock_sleep):
+ """
+ Check whether the tap backoffs properly for 5 times in case of BadGateway error.
+ """
+ mocked_google_ads_client = Mock()
+ mocked_google_ads_client.search.side_effect = BadGateway("Bad Gateway")
+
+ try:
+ make_request(mocked_google_ads_client, "", "")
+ except BadGateway:
+ pass
+
+ # Verify that tap backoff for 5 times
+ self.assertEquals(mocked_google_ads_client.search.call_count, 5)
+
+ def test_503_service_unavailable_error(self, mock_sleep):
+ """
+ Check whether the tap backoffs properly for 5 times in case of ServiceUnavailable error.
+ """
+ mocked_google_ads_client = Mock()
+ mocked_google_ads_client.search.side_effect = ServiceUnavailable("Service Unavailable")
+
+ try:
+ make_request(mocked_google_ads_client, "", "")
+ except ServiceUnavailable:
+ pass
+
+ # Verify that tap backoff for 5 times
+ self.assertEquals(mocked_google_ads_client.search.call_count, 5)
+
+ def test_504_gateway_timeout_error(self, mock_sleep):
+ """
+ Check whether the tap backoffs properly for 5 times in case of GatewayTimeout error.
+ """
+ mocked_google_ads_client = Mock()
+ mocked_google_ads_client.search.side_effect = GatewayTimeout("GatewayTimeout")
+
+ try:
+ make_request(mocked_google_ads_client, "", "")
+ except GatewayTimeout:
+ pass
+
+ # Verify that tap backoff for 5 times
+ self.assertEquals(mocked_google_ads_client.search.call_count, 5)
+
+ def test_429_too_may_request_error(self, mock_sleep):
+ """
+ Check whether the tap backoffs properly for 5 times in case of TooManyRequests error.
+ """
+ mocked_google_ads_client = Mock()
+ mocked_google_ads_client.search.side_effect = TooManyRequests("Resource has been exhausted")
+
+ try:
+ make_request(mocked_google_ads_client, "", "")
+ except TooManyRequests:
+ pass
+
+ # Verify that tap backoff for 5 times
+ self.assertEquals(mocked_google_ads_client.search.call_count, 5)
+
+ def test_read_timeout_error(self, mock_sleep):
+ """
+ Check whether the tap backoffs properly for 5 times in case of ReadTimeout error.
+ """
+ mocked_google_ads_client = Mock()
+ mocked_google_ads_client.search.side_effect = ReadTimeout("HTTPSConnectionPool(host='tap-tester-api.sandbox.stitchdata.com', port=443)")
+
+ try:
+ make_request(mocked_google_ads_client, "", "")
+ except ReadTimeout:
+ pass
+
+ # Verify that tap backoff for 5 times
+ self.assertEquals(mocked_google_ads_client.search.call_count, 5)
diff --git a/tests/unittests/test_core_stream_query_building.py b/tests/unittests/test_core_stream_query_building.py
new file mode 100644
index 0000000..a519292
--- /dev/null
+++ b/tests/unittests/test_core_stream_query_building.py
@@ -0,0 +1,70 @@
+import unittest
+from tap_google_ads.streams import create_core_stream_query
+
+SELECTED_FIELDS = ["id"]
+RESOURCE_NAME = "ads"
+
+class TestFullTableQuery(unittest.TestCase):
+ """
+ Test that `create_core_stream_query` function build appropriate query with WHERE, ORDER BY clause.
+ """
+ def test_empty_filter_params_clause(self):
+ """
+ Verify that query does not contain WHERE and ORDER BY clause if filter_params value is None.
+ """
+
+ filter_params = None
+ last_pk_fetched = {}
+ composite_pks = False
+
+ expected_query = 'SELECT id FROM ads PARAMETERS omit_unselected_resource_names=true'
+
+ actual_query = create_core_stream_query(RESOURCE_NAME, SELECTED_FIELDS, last_pk_fetched, filter_params, composite_pks)
+
+ self.assertEqual(expected_query, actual_query)
+
+ def test_empty_where_clause(self):
+ """
+ Verify that query contain only ORDER BY clause if filter_params value is not None and
+ last_pk_fetched is empty.(Fresh sync)
+ """
+ filter_params = 'id'
+ last_pk_fetched = {}
+ composite_pks = False
+ expected_query = 'SELECT id FROM ads ORDER BY id ASC PARAMETERS omit_unselected_resource_names=true'
+
+ actual_query = create_core_stream_query(RESOURCE_NAME, SELECTED_FIELDS, last_pk_fetched, filter_params, composite_pks)
+
+ self.assertEqual(expected_query, actual_query)
+
+ def test_where_orderby_clause_composite_pks(self):
+ """
+ Verify that query contains WHERE(inclusive) and ORDER BY clause if filter_params and
+ last_pk_fetched are available. (interrupted sync). WHERE clause must have equality if stream contain
+ a composite primary key.
+ """
+ filter_params = 'id'
+ last_pk_fetched = 4
+ composite_pks = True
+
+ expected_query = 'SELECT id FROM ads WHERE id >= 4 ORDER BY id ASC PARAMETERS omit_unselected_resource_names=true'
+
+ actual_query = create_core_stream_query(RESOURCE_NAME, SELECTED_FIELDS, last_pk_fetched, filter_params, composite_pks)
+
+ self.assertEqual(expected_query, actual_query)
+
+ def test_where_orderby_clause_non_composite_pks(self):
+ """
+ Verify that query contains WHERE(exclusive) and ORDER BY clause if filter_params and
+ last_pk_fetched are available. (interrupted sync). WHERE clause must exclude equality if stream does not contain
+ a composite primary key.
+ """
+ filter_params = 'id'
+ last_pk_fetched = 4
+ composite_pks = False
+
+ expected_query = 'SELECT id FROM ads WHERE id > 4 ORDER BY id ASC PARAMETERS omit_unselected_resource_names=true'
+
+ actual_query = create_core_stream_query(RESOURCE_NAME, SELECTED_FIELDS, last_pk_fetched, filter_params, composite_pks)
+
+ self.assertEqual(expected_query, actual_query)
From e4aab119fdbe527ec7de84d99ed809277a21982d Mon Sep 17 00:00:00 2001
From: KrisPersonal <66801357+KrisPersonal@users.noreply.github.com>
Date: Wed, 15 Jun 2022 13:59:10 -0700
Subject: [PATCH 52/69] Bump version (#67)
Co-authored-by: KrishnanG
---
CHANGELOG.md | 4 ++++
setup.py | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9aa24b..afc971a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## v1.3.1
+ * Handle uncaught exceptions [#61](https://github.com/singer-io/tap-google-ads/pull/61)
+ * Implement interruptible full table streams [#60](https://github.com/singer-io/tap-google-ads/pull/60)
+
## v1.3.0 [#58](https://github.com/singer-io/tap-google-ads/pull/58)
* Adds several new core streams including ad_group_criterion, campaign_criterion, and their attributed resources.
* Adds new subclass UserInterestStream to handle stream specific name transformations.
diff --git a/setup.py b/setup.py
index 3302101..c4f7ad9 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.3.0',
+ version='1.3.1',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From 7362c5887cc7a5a344ccd70e59ba8b2eab5b721e Mon Sep 17 00:00:00 2001
From: namrata270998 <75604662+namrata270998@users.noreply.github.com>
Date: Thu, 30 Jun 2022 20:13:36 +0530
Subject: [PATCH 53/69] TDL-18524 updated readme and added sample config (#51)
* updated readme and added sample config
* updated endpoints
* add new streams
* resolved PR comments
* fixed a typo
* added the
---
README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++-
config.sample.json | 8 +++++++
2 files changed, 65 insertions(+), 1 deletion(-)
create mode 100644 config.sample.json
diff --git a/README.md b/README.md
index 4538134..5c0ea3f 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,64 @@ spec](https://github.com/singer-io/getting-started/blob/master/SPEC.md).
This tap:
-- Pulls raw data from the [Google Ads API](https://developers.google.com/google-ads/api/docs/start).
+- Pulls data from the [Google Ads API](https://developers.google.com/google-ads/api/docs/start).
+- Extracts the following resources from Google Ads
+ - [Accessible Bidding Strategies](https://developers.google.com/google-ads/api/reference/rpc/v10/AccessibleBiddingStrategy)
+ - [Accounts](https://developers.google.com/google-ads/api/reference/rpc/v10/Customer)
+ - [Ad Groups](https://developers.google.com/google-ads/api/reference/rpc/v10/AdGroup)
+ - [Ads](https://developers.google.com/google-ads/api/reference/rpc/v10/Ad)
+ - [Bidding Strategies](https://developers.google.com/google-ads/api/reference/rpc/v10/BiddingStrategy)
+ - [Call Details](https://developers.google.com/google-ads/api/reference/rpc/v10/CallView)
+ - [Campaigns](https://developers.google.com/google-ads/api/reference/rpc/v10/Campaign)
+ - [Campaign Budgets](https://developers.google.com/google-ads/api/reference/rpc/v10/CampaignBudget)
+ - [Campaign Labels](https://developers.google.com/google-ads/api/reference/rpc/v10/CampaignLabel)
+ - [Labels](https://developers.google.com/google-ads/api/reference/rpc/v10/Label)
+ - [Reporting](https://developers.google.com/google-ads/api/docs/reporting/overview)
+ - [Account Performance Report](https://developers.google.com/google-ads/api/fields/v10/customer)
+ - [Ad Group Performance Report](https://developers.google.com/google-ads/api/fields/v10/ad_group)
+ - [Ad Group Audience Performance Report](https://developers.google.com/google-ads/api/fields/v10/ad_group_audience_view)
+ - [Ad Performance Report](https://developers.google.com/google-ads/api/fields/v10/ad_group_ad)
+ - [Age Range Performance Report](https://developers.google.com/google-ads/api/fields/v10/age_range_view)
+ - [Campaign Performance Report](https://developers.google.com/google-ads/api/fields/v10/campaign)
+ - [Campaign Audience Performance Report](https://developers.google.com/google-ads/api/fields/v10/campaign_audience_view)
+ - [Call Metrics Call Details Report](https://developers.google.com/google-ads/api/fields/v10/call_view)
+ - [Click Performance Report](https://developers.google.com/google-ads/api/fields/v10/click_view)
+ - [Display Keyword Performance Report](https://developers.google.com/google-ads/api/fields/v10/display_keyword_view)
+ - [Display Topics Performance Report](https://developers.google.com/google-ads/api/fields/v10/topic_view)
+ - [Expanded Landing Page Report](https://developers.google.com/google-ads/api/fields/v10/expanded_landing_page_view)
+ - [Gender Performance Report](https://developers.google.com/google-ads/api/fields/v10/gender_view)
+ - [Geo Performance Report](https://developers.google.com/google-ads/api/fields/v10/geographic_view)
+ - [Keywordless Query Report](https://developers.google.com/google-ads/api/fields/v10/dynamic_search_ads_search_term_view)
+ - [Keywords Performance Report](https://developers.google.com/google-ads/api/fields/v10/keyword_view)
+ - [Landing Page Report](https://developers.google.com/google-ads/api/fields/v10/landing_page_view)
+ - [Placeholder Feed Item Report](https://developers.google.com/google-ads/api/fields/v10/feed_item)
+ - [Placeholder Report](https://developers.google.com/google-ads/api/fields/v10/feed_placeholder_view)
+ - [Placement Performance Report](https://developers.google.com/google-ads/api/fields/v10/managed_placement_view)
+ - [Search Query Performance Report](https://developers.google.com/google-ads/api/fields/v10/search_term_view)
+ - [Shopping Performance Report](https://developers.google.com/google-ads/api/fields/v10/shopping_performance_view)
+ - [User Location Performance Report](https://developers.google.com/google-ads/api/fields/v10/user_location_view)
+ - [UserLocation Performance Report](https://developers.google.com/google-ads/api/fields/v10/user_location_view)
+ - [Video Performance Report](https://developers.google.com/google-ads/api/fields/v10/video)
+## Bookmarking Strategy
+
+The Google Ads API supports the `start_date` and `end_date` parameters that limits the records which filters the analytics records in the given time period.
+
+## Configuration
+
+This tap requires a `config.json` which specifies details regarding [OAuth 2.0](https://developers.google.com/google-ads/api/docs/oauth/overview) authentication and a cutoff date for syncing historical data. See [config.sample.json](config.sample.json) for an example.
+
+To run the discover mode of `tap-google-ads` with the configuration file, use this command:
+
+```bash
+$ tap-google-ads -c my-config.json -d
+```
+
+To run the sync mode of `tap-google-ads` with the catalog file, use the command:
+
+```bash
+$ tap-google-ads -c my-config.json --catalog catalog.json
+```
---
Copyright © 2021 Stitch
diff --git a/config.sample.json b/config.sample.json
new file mode 100644
index 0000000..48e426b
--- /dev/null
+++ b/config.sample.json
@@ -0,0 +1,8 @@
+{
+ "start_date": "2020-10-01T00:00:00Z",
+ "login_customer_ids": [{"customerId": "1234567890", "loginCustomerId": "0987654321"}],
+ "oauth_client_id":"client_id",
+ "oauth_client_secret":"client_secret",
+ "refresh_token":"refresh_token",
+ "developer_token":"developer_token"
+}
\ No newline at end of file
From 90c467783c29630f95e01eb9f0478df67ac9e21e Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Thu, 30 Jun 2022 11:01:52 -0400
Subject: [PATCH 54/69] Add timeout parameter to gas.search (#64)
* Add timeout parameter to search gas.search
* Increase timeout to 15 minutes for safety.
* Add get_request_timeout function, add config to make_request as needed
* Update on_giveup to raise specific exception text for timeoutexception class; add unit test
* Make Pylint happy take 1
* Update make_request signature in unittests.
* Another Unittest update
* More unittest fixes for signature
* Add default config param value for ease of implementation in future tests
* Fix pylint dangerous-defaul-value
* Fix stupid error
---
tap_google_ads/streams.py | 44 ++++++++++++++++++-----
tap_google_ads/sync.py | 2 --
tests/unittests/test_backoff.py | 3 ++
tests/unittests/test_conversion_window.py | 12 +++----
tests/unittests/test_request_timeout.py | 39 ++++++++++++++++++++
tests/unittests/test_sync.py | 4 +--
6 files changed, 86 insertions(+), 18 deletions(-)
create mode 100644 tests/unittests/test_request_timeout.py
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 57cd39e..03c9b45 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -28,6 +28,8 @@
DEFAULT_CONVERSION_WINDOW = 30
DEFAULT_PAGE_SIZE = 1000
+DEFAULT_REQUEST_TIMEOUT = 900 # in seconds
+
def get_conversion_window(config):
"""Fetch the conversion window from the config and error on invalid values"""
@@ -43,6 +45,18 @@ def get_conversion_window(config):
raise RuntimeError("Conversion Window must be between 1 - 30 inclusive, 60, or 90")
+
+def get_request_timeout(config):
+ """Get `request_timeout` value from config and error on invalid values"""
+ request_timeout = config.get("request_timeout") or DEFAULT_REQUEST_TIMEOUT
+
+ try:
+ request_timeout = int(request_timeout)
+ except (ValueError, TypeError):
+ LOGGER.warning(f"The provided request_timeout {request_timeout} is invalid; it will be set to the default request timeout of {DEFAULT_REQUEST_TIMEOUT}.")
+ request_timeout = DEFAULT_REQUEST_TIMEOUT
+ return request_timeout
+
def create_nested_resource_schema(resource_schema, fields):
new_schema = {
"type": ["null", "object"],
@@ -160,6 +174,10 @@ def generate_hash(record, metadata):
return hashlib.sha256(hash_bytes).hexdigest()
+class TimeoutException(Exception):
+ pass
+
+
retryable_errors = [
"QuotaError.RESOURCE_EXHAUSTED",
"QuotaError.RESOURCE_TEMPORARILY_EXHAUSTED",
@@ -168,6 +186,10 @@ def generate_hash(record, metadata):
"InternalError.DEADLINE_EXCEEDED",
]
+timeout_errors = [
+ "RequestError.RPC_DEADLINE_TOO_SHORT",
+]
+
def should_give_up(ex):
@@ -186,17 +208,20 @@ def should_give_up(ex):
for googleads_error in ex.failure.errors:
quota_error = str(googleads_error.error_code.quota_error)
internal_error = str(googleads_error.error_code.internal_error)
- for err in [quota_error, internal_error]:
+ request_error = str(googleads_error.error_code.request_error)
+ for err in [quota_error, internal_error, request_error]:
if err in retryable_errors:
LOGGER.info(f'Retrying request due to {err}')
return False
- return True
+ if err in timeout_errors:
+ raise TimeoutException('Request was not able to complete within allotted timeout. Try reducing the amount of data being requested before increasing timeout.')
+ return True
def on_giveup_func(err):
"""This function lets us know that backoff ran, but it does not print
Google's verbose message and stack trace"""
- LOGGER.warning("Giving up make_request after %s tries", err.get("tries"))
+ LOGGER.warning("Giving up request after %s tries", err.get("tries"))
@backoff.on_exception(backoff.expo,
@@ -208,9 +233,12 @@ def on_giveup_func(err):
jitter=None,
giveup=should_give_up,
on_giveup=on_giveup_func,
- )
-def make_request(gas, query, customer_id):
- response = gas.search(query=query, customer_id=customer_id)
+ logger=None)
+def make_request(gas, query, customer_id, config=None):
+ if config is None:
+ config = {}
+ request_timeout = get_request_timeout(config)
+ response = gas.search(query=query, customer_id=customer_id, timeout=request_timeout)
return response
@@ -403,7 +431,7 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
query = create_core_stream_query(resource_name, selected_fields, last_pk_fetched.get('last_pk_fetched'), self.filter_param, composite_pks)
try:
- response = make_request(gas, query, customer["customerId"])
+ response = make_request(gas, query, customer["customerId"], config)
except GoogleAdsException as err:
LOGGER.warning("Failed query: %s", query)
raise err
@@ -672,7 +700,7 @@ def sync(self, sdk_client, customer, stream, config, state):
LOGGER.info(f"Requesting {stream_name} data for {utils.strftime(query_date, '%Y-%m-%d')}.")
try:
- response = make_request(gas, query, customer["customerId"])
+ response = make_request(gas, query, customer["customerId"], config)
except GoogleAdsException as err:
LOGGER.warning("Failed query: %s", query)
LOGGER.critical(str(err.failure.errors[0].message))
diff --git a/tap_google_ads/sync.py b/tap_google_ads/sync.py
index 3f2e81c..8186339 100644
--- a/tap_google_ads/sync.py
+++ b/tap_google_ads/sync.py
@@ -1,7 +1,5 @@
import json
-
import singer
-
from tap_google_ads.client import create_sdk_client
from tap_google_ads.streams import initialize_core_streams, initialize_reports
diff --git a/tests/unittests/test_backoff.py b/tests/unittests/test_backoff.py
index eef14fe..d3d1497 100644
--- a/tests/unittests/test_backoff.py
+++ b/tests/unittests/test_backoff.py
@@ -111,3 +111,6 @@ def test_read_timeout_error(self, mock_sleep):
# Verify that tap backoff for 5 times
self.assertEquals(mocked_google_ads_client.search.call_count, 5)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unittests/test_conversion_window.py b/tests/unittests/test_conversion_window.py
index e1b65a9..2b663dc 100644
--- a/tests/unittests/test_conversion_window.py
+++ b/tests/unittests/test_conversion_window.py
@@ -72,8 +72,8 @@ def execute(self, conversion_window, fake_make_request):
)
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
- # The function signature is gas, query, customer_id
- _, query, _ = request_sent.args
+ # The function signature is gas, query, customer_id, config
+ _, query, _, _ = request_sent.args
all_queries_requested.append(query)
@@ -146,8 +146,8 @@ def execute(self, conversion_window, fake_make_request):
)
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
- # The function signature is gas, query, customer_id
- _, query, _ = request_sent.args
+ # The function signature is gas, query, customer_id, config
+ _, query, _, _ = request_sent.args
all_queries_requested.append(query)
@@ -216,8 +216,8 @@ def execute(self, conversion_window, fake_make_request):
)
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
- # The function signature is gas, query, customer_id
- _, query, _ = request_sent.args
+ # The function signature is gas, query, customer_id, config
+ _, query, _, _ = request_sent.args
all_queries_requested.append(query)
diff --git a/tests/unittests/test_request_timeout.py b/tests/unittests/test_request_timeout.py
new file mode 100644
index 0000000..b86b89b
--- /dev/null
+++ b/tests/unittests/test_request_timeout.py
@@ -0,0 +1,39 @@
+import unittest
+from tap_google_ads.streams import get_request_timeout
+
+config_no_timeout = {}
+config_int_timeout = {"request_timeout": 100}
+config_float_timeout = {"request_timeout": 100.0}
+config_str_timeout = {"request_timeout": "100"}
+config_empty_str_timeout = {"request_timeout": ""}
+
+
+class TestGetRequestTimeout(unittest.TestCase):
+
+ def test_no_timeout(self):
+ actual = get_request_timeout(config_no_timeout)
+ expected = 900
+ self.assertEqual(expected, actual)
+
+ def test_valid_timeout(self):
+ actual = get_request_timeout(config_int_timeout)
+ expected = 100
+ self.assertEqual(expected, actual)
+
+ def test_string_timeout(self):
+ actual = get_request_timeout(config_str_timeout)
+ expected = 100
+ self.assertEqual(expected, actual)
+
+ def test_empty_string_timeout(self):
+ actual = get_request_timeout(config_empty_str_timeout)
+ expected = 900
+ self.assertEqual(expected, actual)
+
+ def test_float_timeout(self):
+ actual = get_request_timeout(config_float_timeout)
+ expected = 100
+ self.assertEqual(expected, actual)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unittests/test_sync.py b/tests/unittests/test_sync.py
index 7e44019..21eb784 100644
--- a/tests/unittests/test_sync.py
+++ b/tests/unittests/test_sync.py
@@ -21,8 +21,8 @@ class TestEndDate(unittest.TestCase):
def get_queries_from_sync(self, fake_make_request):
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
- # The function signature is gas, query, customer_id
- _, query, _ = request_sent.args
+ # The function signature is gas, query, customer_id, config
+ _, query, _, _ = request_sent.args
all_queries_requested.append(query)
return all_queries_requested
From bec7b81ffbf55bcc2336138abdd61ab04096013a Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Thu, 30 Jun 2022 11:02:49 -0400
Subject: [PATCH 55/69] Version bump and changelog (#70)
---
CHANGELOG.md | 4 ++++
setup.py | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afc971a..dbbf75d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## v1.3.2
+ * Add timeout parameter to Google Ads search requests
+ * Allow for request_timeout config parameter to be provided [#64](https://github.com/singer-io/tap-google-ads/pull/64)
+
## v1.3.1
* Handle uncaught exceptions [#61](https://github.com/singer-io/tap-google-ads/pull/61)
* Implement interruptible full table streams [#60](https://github.com/singer-io/tap-google-ads/pull/60)
diff --git a/setup.py b/setup.py
index c4f7ad9..2348574 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.3.1',
+ version='1.3.2',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From 2b9d9d2df7fd5728211e919bedb9032dd9551175 Mon Sep 17 00:00:00 2001
From: Prijen Khokhani <88327452+prijendev@users.noreply.github.com>
Date: Tue, 5 Jul 2022 18:32:59 +0530
Subject: [PATCH 56/69] TDL-19486 Add limit clause to core stream queries (#68)
* Initial commit for add page limit.
* Added limit parameter in sync method of ReportStream class.
* Fixed issue for call_details stream.
* Resolved unit test case error.
* Added test cases.
* Updated code comments.
* Fixed keyerror issue.
* Updated default query limit.
* Updated pagination test case.
* Modify config name to be more explicit.
* Update comment for accuracy.
* Update property name in base and propogate name change to tests
* Exclude feed from streams_to_test because of lack of data
* Committing stuff from main that should have already been in
Co-authored-by: dsprayberry <28106103+dsprayberry@users.noreply.github.com>
---
config.sample.json | 8 --
tap_google_ads/streams.py | 90 ++++++++++++++++-------
tap_google_ads/sync.py | 22 +++++-
tests/base.py | 1 +
tests/test_google_ads_page_limit.py | 83 +++++++++++++++++++++
tests/unittests/test_conversion_window.py | 3 +
tests/unittests/test_query_limit_param.py | 83 +++++++++++++++++++++
tests/unittests/test_request_timeout.py | 39 ----------
tests/unittests/test_sync.py | 3 +-
9 files changed, 258 insertions(+), 74 deletions(-)
delete mode 100644 config.sample.json
create mode 100644 tests/test_google_ads_page_limit.py
create mode 100644 tests/unittests/test_query_limit_param.py
delete mode 100644 tests/unittests/test_request_timeout.py
diff --git a/config.sample.json b/config.sample.json
deleted file mode 100644
index 48e426b..0000000
--- a/config.sample.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "start_date": "2020-10-01T00:00:00Z",
- "login_customer_ids": [{"customerId": "1234567890", "loginCustomerId": "0987654321"}],
- "oauth_client_id":"client_id",
- "oauth_client_secret":"client_secret",
- "refresh_token":"refresh_token",
- "developer_token":"developer_token"
-}
\ No newline at end of file
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 03c9b45..2ecc21e 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -27,7 +27,6 @@
)
DEFAULT_CONVERSION_WINDOW = 30
-DEFAULT_PAGE_SIZE = 1000
DEFAULT_REQUEST_TIMEOUT = 900 # in seconds
@@ -143,12 +142,16 @@ def generate_where_and_orderby_clause(last_pk_fetched, filter_param, composite_p
return f'{where_clause}{order_by_clause}'
-def create_core_stream_query(resource_name, selected_fields, last_pk_fetched, filter_param, composite_pks):
+def create_core_stream_query(resource_name, selected_fields, last_pk_fetched, filter_param, composite_pks, limit=None):
# Generate a query using WHERE and ORDER BY parameters.
where_order_by_clause = generate_where_and_orderby_clause(last_pk_fetched, filter_param, composite_pks)
- core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} {where_order_by_clause} {build_parameters()}"
+ if limit:
+ # Add a LIMIT clause in the query of core streams.
+ core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} {where_order_by_clause} LIMIT {limit} {build_parameters()}"
+ else:
+ core_query = f"SELECT {','.join(selected_fields)} FROM {resource_name} {where_order_by_clause} {build_parameters()}"
return core_query
@@ -259,6 +262,12 @@ def filter_out_non_attribute_fields(fields):
for field_name, field_data in fields.items()
if field_data["field_details"]["category"] == "ATTRIBUTE"}
+def write_bookmark_for_core_streams(state, stream, customer_id, last_pk_fetched):
+ # Write bookmark for core streams.
+ singer.write_bookmark(state, stream, customer_id, {'last_pk_fetched': last_pk_fetched})
+
+ singer.write_state(state)
+ LOGGER.info("Write state for stream: %s, value: %s", stream, last_pk_fetched)
class BaseStream: # pylint: disable=too-many-instance-attributes
@@ -412,7 +421,7 @@ def transform_keys(self, json_message):
return transformed_message
- def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=unused-argument
+ def sync(self, sdk_client, customer, stream, config, state, query_limit): # pylint: disable=unused-argument
gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
resource_name = self.google_ads_resource_names[0]
stream_name = stream["stream"]
@@ -429,31 +438,62 @@ def sync(self, sdk_client, customer, stream, config, state): # pylint: disable=u
# Assign True if the primary key is composite.
composite_pks = len(self.primary_keys) > 1
- query = create_core_stream_query(resource_name, selected_fields, last_pk_fetched.get('last_pk_fetched'), self.filter_param, composite_pks)
- try:
- response = make_request(gas, query, customer["customerId"], config)
- except GoogleAdsException as err:
- LOGGER.warning("Failed query: %s", query)
- raise err
+ # LIMIT clause in the `ad_group_criterion` and `campaign_criterion`(stream which has composite primary keys) may result in the infinite loop.
+ # For example, the limit is 10. campaign_criterion stream have total 20 records with campaign_id = 1.
+ # So, in the first call, the tap retrieves 10 records and the next time query would look like the below,
+ # WHERE campaign_id >= 1
+ # Now, the tap will again fetch records with campaign_id = 1.
+ # That's why we should not pass the LIMIT clause in the query of these streams.
+ limit_not_possible = ["ad_group_criterion", "campaign_criterion"]
+
+ # Set limit for the stream which supports filter parameter(WHERE clause) and do not belong to limit_not_possible category.
+ if self.filter_param and stream_name not in limit_not_possible:
+ limit = query_limit
+ else:
+ limit = None
+
+ is_more_records = True
+ record = None
+ # Retrieve the last saved state. If last_pk_fetched is not found in the state, then the WHERE clause will not be added to the state.
+ last_pk_fetched_value = last_pk_fetched.get('last_pk_fetched')
with metrics.record_counter(stream_name) as counter:
- with Transformer() as transformer:
- # Pages are fetched automatically while iterating through the response
- for message in response:
- json_message = google_message_to_json(message)
- transformed_message = self.transform_keys(json_message)
- record = transformer.transform(transformed_message, stream["schema"], singer.metadata.to_map(stream_mdata))
- singer.write_record(stream_name, record)
- counter.increment()
+ # Loop until the last page.
+ while is_more_records:
+ query = create_core_stream_query(resource_name, selected_fields, last_pk_fetched_value, self.filter_param, composite_pks, limit=limit)
+ try:
+ response = make_request(gas, query, customer["customerId"], config)
+ except GoogleAdsException as err:
+ LOGGER.warning("Failed query: %s", query)
+ raise err
+ num_rows = 0
+
+ with Transformer() as transformer:
+ # Pages are fetched automatically while iterating through the response
+ for message in response:
+ json_message = google_message_to_json(message)
+ transformed_message = self.transform_keys(json_message)
+ record = transformer.transform(transformed_message, stream["schema"], singer.metadata.to_map(stream_mdata))
+ singer.write_record(stream_name, record)
+ counter.increment()
+ num_rows = num_rows + 1
+ if stream_name in limit_not_possible:
+ # Write state(last_pk_fetched) using primary key(id) value for core streams after query_limit records
+ if counter.value % query_limit == 0 and self.filter_param:
+ write_bookmark_for_core_streams(state, stream["tap_stream_id"], customer["customerId"], record[self.primary_keys[0]])
+
+ if record and self.filter_param and stream_name not in limit_not_possible:
+ # Write the id of the last record for the stream, which supports the filter parameter(WHERE clause) and do not belong to limit_not_possible category.
+ write_bookmark_for_core_streams(state, stream["tap_stream_id"], customer["customerId"], record[self.primary_keys[0]])
+ last_pk_fetched_value = record[self.primary_keys[0]]
+ # Fetch the next page of records
+ if num_rows >= limit:
+ continue
- # Write state(last_pk_fetched) using primary key(id) value for core streams after DEFAULT_PAGE_SIZE records
- if counter.value % DEFAULT_PAGE_SIZE == 0 and self.filter_param:
- bookmark_value = record[self.primary_keys[0]]
- singer.write_bookmark(state, stream["tap_stream_id"], customer["customerId"], {'last_pk_fetched': bookmark_value})
+ # Break the loop if no more records are available or the LIMIT clause is not possible.
+ is_more_records = False
- singer.write_state(state)
- LOGGER.info("Write state for stream: %s, value: %s", stream_name, bookmark_value)
# Flush the state for core streams if sync is completed
if stream["tap_stream_id"] in state.get('bookmarks', {}):
@@ -655,7 +695,7 @@ def transform_keys(self, json_message):
return transformed_message
- def sync(self, sdk_client, customer, stream, config, state):
+ def sync(self, sdk_client, customer, stream, config, state, query_limit):
gas = sdk_client.get_service("GoogleAdsService", version=API_VERSION)
resource_name = self.google_ads_resource_names[0]
stream_name = stream["stream"]
diff --git a/tap_google_ads/sync.py b/tap_google_ads/sync.py
index 8186339..829e8c7 100644
--- a/tap_google_ads/sync.py
+++ b/tap_google_ads/sync.py
@@ -4,6 +4,7 @@
from tap_google_ads.streams import initialize_core_streams, initialize_reports
LOGGER = singer.get_logger()
+DEFAULT_QUERY_LIMIT = 1000000
def get_currently_syncing(state):
@@ -52,6 +53,22 @@ def shuffle(shuffle_list, shuffle_key, current_value, sort_function):
return top_half + bottom_half
+def get_query_limit(config):
+ """
+ This function will get the query_limit from config,
+ and will return the default value if an invalid query limit is given.
+ """
+ query_limit = config.get('query_limit', DEFAULT_QUERY_LIMIT)
+
+ try:
+ if int(float(query_limit)) > 0:
+ return int(float(query_limit))
+ else:
+ LOGGER.warning(f"The entered query limit is invalid; it will be set to the default query limit of {DEFAULT_QUERY_LIMIT}")
+ return DEFAULT_QUERY_LIMIT
+ except Exception:
+ LOGGER.warning(f"The entered query limit is invalid; it will be set to the default query limit of {DEFAULT_QUERY_LIMIT}")
+ return DEFAULT_QUERY_LIMIT
def do_sync(config, catalog, resource_schema, state):
# QA ADDED WORKAROUND [START]
@@ -59,6 +76,9 @@ def do_sync(config, catalog, resource_schema, state):
customers = json.loads(config["login_customer_ids"])
except TypeError: # falling back to raw value
customers = config["login_customer_ids"]
+
+ # Get query limit
+ query_limit = get_query_limit(config)
# QA ADDED WORKAROUND [END]
customers = sort_customers(customers)
@@ -106,7 +126,7 @@ def do_sync(config, catalog, resource_schema, state):
else:
stream_obj = report_streams[stream_name]
- stream_obj.sync(sdk_client, customer, catalog_entry, config, state)
+ stream_obj.sync(sdk_client, customer, catalog_entry, config, state, query_limit=query_limit)
state.pop("currently_syncing", None)
singer.write_state(state)
diff --git a/tests/base.py b/tests/base.py
index 03ed2ae..5bd8e5d 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -53,6 +53,7 @@ def get_properties(self, original: bool = True):
'start_date': '2021-12-01T00:00:00Z',
'user_id': 'not used?', # Useless config property carried over from AdWords
'customer_ids': ','.join(self.get_customer_ids()),
+ 'query_limit': 2,
# 'conversion_window_days': '30',
'login_customer_ids': [{"customerId": os.getenv('TAP_GOOGLE_ADS_CUSTOMER_ID'),
"loginCustomerId": os.getenv('TAP_GOOGLE_ADS_LOGIN_CUSTOMER_ID'),}],
diff --git a/tests/test_google_ads_page_limit.py b/tests/test_google_ads_page_limit.py
new file mode 100644
index 0000000..72ada44
--- /dev/null
+++ b/tests/test_google_ads_page_limit.py
@@ -0,0 +1,83 @@
+from tap_tester import menagerie, connections, runner
+from math import ceil
+from base import GoogleAdsBase
+
+class TesGoogleAdstPagination(GoogleAdsBase):
+ """
+ Ensure tap can replicate multiple pages of data for streams that use query limit.
+ """
+ DEFAULT_QUERY_LIMIT = 2
+
+ @staticmethod
+ def name():
+ return "tt_google_ads_pagination"
+
+ def test_run(self):
+ """
+ • Verify that for each stream you can get multiple pages of data.
+ This requires we ensure more than 1 page of data exists at all times for any given stream.
+ • Verify by pks that the data replicated matches the data we expect.
+ """
+ streams_to_test = {stream for stream in self.expected_streams()
+ if not self.is_report(stream)}
+
+ # LIMIT parameter is not availble for call_details, campaign_labels, campaign_criterion, ad_group_criterion
+ streams_to_test = streams_to_test - {'ad_group_criterion', 'call_details', 'campaign_labels', 'campaign_criterion'}
+
+ # We do not have enough records for accessible_bidding_strategies, accounts, bidding_strategies, feed, and user_list streams.
+ streams_to_test = streams_to_test - {'accessible_bidding_strategies', 'accounts', 'bidding_strategies', 'feed', 'user_list'}
+
+ # Create connection
+ conn_id = connections.ensure_connection(self)
+
+ # Run a discovery job
+ found_catalogs = self.run_and_verify_check_mode(conn_id)
+
+ # Partition catalogs for use in table/field selection
+ test_catalogs = [catalog for catalog in found_catalogs
+ if catalog.get('stream_name') in streams_to_test]
+
+ # Select fields
+ self.select_all_streams_and_fields(conn_id, test_catalogs, select_all_fields=False)
+
+ # Run a sync
+ record_count_by_stream = self.run_and_verify_sync(conn_id)
+
+ # Acquire records from target output
+ synced_records = runner.get_records_from_target_output()
+
+ for stream in streams_to_test:
+ with self.subTest(stream=stream):
+
+ # Expected values
+ expected_primary_keys = self.expected_primary_keys()[stream]
+
+ # Verify that we can paginate with all fields selected
+ record_count_sync = record_count_by_stream.get(stream, 0)
+ self.assertGreater(record_count_sync, self.DEFAULT_QUERY_LIMIT,
+ msg="The number of records is not over the stream max limit")
+
+ primary_keys_list = [tuple([message.get('data').get(expected_pk) for expected_pk in expected_primary_keys])
+ for message in synced_records.get(stream).get('messages')
+ if message.get('action') == 'upsert']
+
+ # Chunk the replicated records (just primary keys) into expected pages
+ pages = []
+ page_count = ceil(len(primary_keys_list) / self.DEFAULT_QUERY_LIMIT)
+ query_limit = self.DEFAULT_QUERY_LIMIT
+ for page_index in range(page_count):
+ page_start = page_index * query_limit
+ page_end = (page_index + 1) * query_limit
+ pages.append(set(primary_keys_list[page_start:page_end]))
+
+ # Verify by primary keys that data is unique for each page
+ for current_index, current_page in enumerate(pages):
+ with self.subTest(current_page_primary_keys=current_page):
+
+ for other_index, other_page in enumerate(pages):
+ if current_index == other_index:
+ continue # don't compare the page to itself
+
+ self.assertTrue(
+ current_page.isdisjoint(other_page), msg=f'other_page_primary_keys={other_page}'
+ )
diff --git a/tests/unittests/test_conversion_window.py b/tests/unittests/test_conversion_window.py
index 2b663dc..f5ed5d0 100644
--- a/tests/unittests/test_conversion_window.py
+++ b/tests/unittests/test_conversion_window.py
@@ -69,6 +69,7 @@ def execute(self, conversion_window, fake_make_request):
"metadata": []},
config,
state,
+ None
)
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
@@ -143,6 +144,7 @@ def execute(self, conversion_window, fake_make_request):
"metadata": []},
config,
state,
+ None
)
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
@@ -213,6 +215,7 @@ def execute(self, conversion_window, fake_make_request):
"metadata": []},
config,
state,
+ None
)
all_queries_requested = []
for request_sent in fake_make_request.call_args_list:
diff --git a/tests/unittests/test_query_limit_param.py b/tests/unittests/test_query_limit_param.py
new file mode 100644
index 0000000..49aef9b
--- /dev/null
+++ b/tests/unittests/test_query_limit_param.py
@@ -0,0 +1,83 @@
+import unittest
+from tap_google_ads.sync import get_query_limit, DEFAULT_QUERY_LIMIT
+
+
+def get_config(value):
+ return {
+ "query_limit": value
+ }
+
+class TestQueryLimitParam(unittest.TestCase):
+
+ """Tests to validate different values of the query_limit parameter"""
+
+ def test_integer_query_limit_field(self):
+ """ Verify that limit is set to 100 if int 100 is given in the config """
+ expected_value = 100
+ actual_value = get_query_limit(get_config(100))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_float_query_limit_field(self):
+ """ Verify that limit is set to 100 if float 100.05 is given in the config """
+
+ expected_value = 100
+ actual_value = get_query_limit(get_config(100.05))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_zero_int_query_limit_field(self):
+ """ Verify that limit is set to DEFAULT_QUERY_LIMIT if 0 is given in the config """
+
+ expected_value = DEFAULT_QUERY_LIMIT
+ actual_value = get_query_limit(get_config(0))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_zero_float_query_limit_field(self):
+ """ Verify that limit is set to DEFAULT_QUERY_LIMIT if 0.5 is given in the config """
+
+ expected_value = DEFAULT_QUERY_LIMIT
+ actual_value = get_query_limit(get_config(0.5))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_empty_string_query_limit_field(self):
+ """ Verify that limit is set to DEFAULT_QUERY_LIMIT if empty string is given in the config """
+
+ expected_value = DEFAULT_QUERY_LIMIT
+ actual_value = get_query_limit(get_config(""))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_string_query_limit_field(self):
+ """ Verify that limit is set to 100 if string "100" is given in the config """
+
+ expected_value = 100
+ actual_value = get_query_limit(get_config("100"))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_invalid_string_query_limit_field(self):
+ """ Verify that limit is set to DEFAULT_QUERY_LIMIT if invalid string is given in the config """
+
+ expected_value = DEFAULT_QUERY_LIMIT
+ actual_value = get_query_limit(get_config("dg%#"))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_negative_int_query_limit_field(self):
+ """ Verify that limit is set to 100 if negative int is given in the config """
+
+ expected_value = DEFAULT_QUERY_LIMIT
+ actual_value = get_query_limit(get_config(-10))
+
+ self.assertEqual(actual_value, expected_value)
+
+ def test_negative_float_query_limit_field(self):
+ """ Verify that limit is set to 100 if negative float is given in the config """
+
+ expected_value = DEFAULT_QUERY_LIMIT
+ actual_value = get_query_limit(get_config(-10.5))
+
+ self.assertEqual(actual_value, expected_value)
diff --git a/tests/unittests/test_request_timeout.py b/tests/unittests/test_request_timeout.py
deleted file mode 100644
index b86b89b..0000000
--- a/tests/unittests/test_request_timeout.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import unittest
-from tap_google_ads.streams import get_request_timeout
-
-config_no_timeout = {}
-config_int_timeout = {"request_timeout": 100}
-config_float_timeout = {"request_timeout": 100.0}
-config_str_timeout = {"request_timeout": "100"}
-config_empty_str_timeout = {"request_timeout": ""}
-
-
-class TestGetRequestTimeout(unittest.TestCase):
-
- def test_no_timeout(self):
- actual = get_request_timeout(config_no_timeout)
- expected = 900
- self.assertEqual(expected, actual)
-
- def test_valid_timeout(self):
- actual = get_request_timeout(config_int_timeout)
- expected = 100
- self.assertEqual(expected, actual)
-
- def test_string_timeout(self):
- actual = get_request_timeout(config_str_timeout)
- expected = 100
- self.assertEqual(expected, actual)
-
- def test_empty_string_timeout(self):
- actual = get_request_timeout(config_empty_str_timeout)
- expected = 900
- self.assertEqual(expected, actual)
-
- def test_float_timeout(self):
- actual = get_request_timeout(config_float_timeout)
- expected = 100
- self.assertEqual(expected, actual)
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/unittests/test_sync.py b/tests/unittests/test_sync.py
index 21eb784..802fe26 100644
--- a/tests/unittests/test_sync.py
+++ b/tests/unittests/test_sync.py
@@ -51,7 +51,8 @@ def run_sync(self, start_date, end_date, fake_make_request):
"stream": "hi",
"metadata": []},
config,
- {}
+ {},
+ None
)
@patch('singer.utils.now')
From 10a10a5458ce119b8d2f91b838075b9685f2d430 Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Tue, 5 Jul 2022 09:03:18 -0400
Subject: [PATCH 57/69] Version Bump and Changelog Update (#72)
---
CHANGELOG.md | 3 +++
setup.py | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbbf75d..3956e5e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## v1.3.3
+ * Update applicable core streams to use limit clause. Updates tests [#68](https://github.com/singer-io/tap-google-ads/pull/68)
+
## v1.3.2
* Add timeout parameter to Google Ads search requests
* Allow for request_timeout config parameter to be provided [#64](https://github.com/singer-io/tap-google-ads/pull/64)
diff --git a/setup.py b/setup.py
index 2348574..e63674b 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.3.2',
+ version='1.3.3',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From a2173d1e2ddedcfe6939db0fb2ed2cfface3e5ec Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Tue, 5 Jul 2022 09:35:06 -0400
Subject: [PATCH 58/69] Reintroduce unintentionally removed files. (#73)
---
config.sample.json | 8 +++++
tests/unittests/test_request_timeout.py | 39 +++++++++++++++++++++++++
2 files changed, 47 insertions(+)
create mode 100644 config.sample.json
create mode 100644 tests/unittests/test_request_timeout.py
diff --git a/config.sample.json b/config.sample.json
new file mode 100644
index 0000000..0be0b86
--- /dev/null
+++ b/config.sample.json
@@ -0,0 +1,8 @@
+{
+ "start_date": "2020-10-01T00:00:00Z",
+ "login_customer_ids": [{"customerId": "1234567890", "loginCustomerId": "0987654321"}],
+ "oauth_client_id":"client_id",
+ "oauth_client_secret":"client_secret",
+ "refresh_token":"refresh_token",
+ "developer_token":"developer_token"
+}
diff --git a/tests/unittests/test_request_timeout.py b/tests/unittests/test_request_timeout.py
new file mode 100644
index 0000000..9e82295
--- /dev/null
+++ b/tests/unittests/test_request_timeout.py
@@ -0,0 +1,39 @@
+import unittest
+from tap_google_ads.streams import get_request_timeout
+
+config_no_timeout = {}
+config_int_timeout = {"request_timeout": 100}
+config_float_timeout = {"request_timeout": 100.0}
+config_str_timeout = {"request_timeout": "100"}
+config_empty_str_timeout = {"request_timeout": ""}
+
+
+class TestGetRequestTimeout(unittest.TestCase):
+
+ def test_no_timeout(self):
+ actual = get_request_timeout(config_no_timeout)
+ expected = 900
+ self.assertEqual(expected, actual)
+
+ def test_valid_timeout(self):
+ actual = get_request_timeout(config_int_timeout)
+ expected = 100
+ self.assertEqual(expected, actual)
+
+ def test_string_timeout(self):
+ actual = get_request_timeout(config_str_timeout)
+ expected = 100
+ self.assertEqual(expected, actual)
+
+ def test_empty_string_timeout(self):
+ actual = get_request_timeout(config_empty_str_timeout)
+ expected = 900
+ self.assertEqual(expected, actual)
+
+ def test_float_timeout(self):
+ actual = get_request_timeout(config_float_timeout)
+ expected = 100
+ self.assertEqual(expected, actual)
+
+if __name__ == '__main__':
+ unittest.main()
From 20389504697e4f2058cca7a1b99fb6222814cbbf Mon Sep 17 00:00:00 2001
From: Dylan <28106103+dsprayberry@users.noreply.github.com>
Date: Thu, 11 Aug 2022 11:00:02 -0400
Subject: [PATCH 59/69] Make tap-tester suite use end_date to reduce test
runtime (#71)
* Make tap-tester suite use end_date to reduce test run-time
* update integration tets to account for end date usage, add a unittest
* fix typo in tests
* save test logs as artifacts
Co-authored-by: kspeer
---
.circleci/config.yml | 4 ++++
tests/base.py | 2 ++
tests/base_google_ads_field_exclusion.py | 1 +
tests/test_google_ads_bookmarks.py | 14 ++++++++------
tests/test_google_ads_field_exclusion_invalid.py | 1 +
tests/unittests/test_sync.py | 11 +++++++++++
6 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index ff7e698..43a11ec 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -80,6 +80,8 @@ jobs:
command: |
aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
source dev_env.sh
+ mkdir /tmp/${CIRCLE_PROJECT_REPONAME}
+ export STITCH_CONFIG_DIR=/tmp/${CIRCLE_PROJECT_REPONAME}
source /usr/local/share/virtualenvs/tap-tester/bin/activate
circleci tests glob "tests/*.py" | circleci tests split > ./tests-to-run
if [ -s ./tests-to-run ]; then
@@ -90,6 +92,8 @@ jobs:
fi
- slack/notify-on-failure:
only_for_branches: main
+ - store_artifacts:
+ path: /tmp/tap-google-ads
workflows:
version: 2
diff --git a/tests/base.py b/tests/base.py
index 5bd8e5d..1ce4060 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -30,6 +30,7 @@ class GoogleAdsBase(unittest.TestCase):
REPLICATION_KEY_FORMAT = "%Y-%m-%dT00:00:00.000000Z"
start_date = ""
+ end_date = "2022-03-15T00:00:00Z"
@staticmethod
def tap_name():
@@ -51,6 +52,7 @@ def get_properties(self, original: bool = True):
"""Configurable properties, with a switch to override the 'start_date' property"""
return_value = {
'start_date': '2021-12-01T00:00:00Z',
+ 'end_date': self.end_date,
'user_id': 'not used?', # Useless config property carried over from AdWords
'customer_ids': ','.join(self.get_customer_ids()),
'query_limit': 2,
diff --git a/tests/base_google_ads_field_exclusion.py b/tests/base_google_ads_field_exclusion.py
index b15298c..2719e91 100644
--- a/tests/base_google_ads_field_exclusion.py
+++ b/tests/base_google_ads_field_exclusion.py
@@ -61,6 +61,7 @@ def run_test(self):
# bump start date from default
self.start_date = dt.strftime(dt.today() - timedelta(days=1), self.START_DATE_FORMAT)
+ self.end_date = None
conn_id = connections.ensure_connection(self, original_properties=False)
# Run a discovery job
diff --git a/tests/test_google_ads_bookmarks.py b/tests/test_google_ads_bookmarks.py
index 945faeb..9a224f0 100644
--- a/tests/test_google_ads_bookmarks.py
+++ b/tests/test_google_ads_bookmarks.py
@@ -42,6 +42,8 @@ def test_run(self):
"""
print("Bookmarks Test for tap-google-ads")
+ self.end_date = "2022-02-01T00:00:00Z"
+
conn_id = connections.ensure_connection(self)
streams_under_test = self.expected_streams() - {
@@ -152,7 +154,7 @@ def test_run(self):
# set expectations
expected_replication_method = self.expected_replication_method()[stream]
conversion_window = timedelta(days=30) # defaulted value
- today_datetime = dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+ end_datetime = dt.strptime(self.end_date, self.START_DATE_FORMAT)
# gather results
records_1 = [message['data'] for message in synced_records_1[stream]['messages']]
records_2 = [message['data'] for message in synced_records_2[stream]['messages']]
@@ -188,15 +190,15 @@ def test_run(self):
self.assertIsInstance(bookmark_value_2, str)
self.assertIsDateFormat(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
- # Verify the bookmark is set based on sync end date (today) for sync 1
- # (The tap replicaates from the start date through to today)
+ # Verify the bookmark is set based on sync end date for sync 1
+ # (The tap replicaates from the start date through to end date)
parsed_bookmark_value_1 = dt.strptime(bookmark_value_1, self.REPLICATION_KEY_FORMAT)
- self.assertEqual(parsed_bookmark_value_1, today_datetime)
+ self.assertEqual(parsed_bookmark_value_1, end_datetime)
# Verify the bookmark is set based on sync execution time for sync 2
- # (The tap replicaates from the manipulated state through to todayf)
+ # (The tap replicaates from the manipulated state through to end date)
parsed_bookmark_value_2 = dt.strptime(bookmark_value_2, self.REPLICATION_KEY_FORMAT)
- self.assertEqual(parsed_bookmark_value_2, today_datetime)
+ self.assertEqual(parsed_bookmark_value_2, end_datetime)
# Verify 2nd sync only replicates records newer than manipulated_state_formatted
for record in records_2:
diff --git a/tests/test_google_ads_field_exclusion_invalid.py b/tests/test_google_ads_field_exclusion_invalid.py
index 2dd8280..3f58183 100644
--- a/tests/test_google_ads_field_exclusion_invalid.py
+++ b/tests/test_google_ads_field_exclusion_invalid.py
@@ -93,6 +93,7 @@ def test_invalid_case(self):
# bump start date from default
self.start_date = dt.strftime(dt.today() - timedelta(days=1), self.START_DATE_FORMAT)
+ self.end_date = None
conn_id = connections.ensure_connection(self, original_properties=False)
# Run a discovery job
diff --git a/tests/unittests/test_sync.py b/tests/unittests/test_sync.py
index 802fe26..6ddc3af 100644
--- a/tests/unittests/test_sync.py
+++ b/tests/unittests/test_sync.py
@@ -120,5 +120,16 @@ def test_end_date_one_day_after_start(self, fake_make_request):
)
)
+ @patch('tap_google_ads.streams.make_request')
+ def test_end_date_one_day_before_start(self, fake_make_request):
+ start_date = datetime(2022, 3, 6, 0, 0, 0)
+ end_date = datetime(2022, 3, 5, 0, 0, 0)
+ self.run_sync(start_date, end_date, fake_make_request)
+ all_queries_requested = self.get_queries_from_sync(fake_make_request)
+
+ # verify no requests are made with an invalid start/end date configuration
+ self.assertEqual(len(all_queries_requested), 0)
+
+
if __name__ == '__main__':
unittest.main()
From 80b8bf8a16df906c79c61533541494e659210075 Mon Sep 17 00:00:00 2001
From: Dylan Sprayberry <28106103+dsprayberry@users.noreply.github.com>
Date: Wed, 25 Jan 2023 15:20:14 -0500
Subject: [PATCH 60/69] TDL-21755 v11 and library bump (#79)
* v11 and library bump
* Trying another library version for tests
* protobuf version decrease to meet google ads lib reqs
---
setup.py | 4 ++--
tap_google_ads/streams.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/setup.py b/setup.py
index e63674b..87b3a4f 100644
--- a/setup.py
+++ b/setup.py
@@ -13,8 +13,8 @@
'singer-python==5.12.2',
'requests==2.26.0',
'backoff==1.8.0',
- 'google-ads==15.0.0',
- 'protobuf==3.17.3',
+ 'google-ads==17.0.0',
+ 'protobuf==3.20.0',
# Necessary to handle gRPC exceptions properly, documented
# in an issue here: https://github.com/googleapis/python-api-core/issues/301
'grpcio-status==1.44.0',
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 2ecc21e..a5d95bb 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -14,7 +14,7 @@
LOGGER = singer.get_logger()
-API_VERSION = "v10"
+API_VERSION = "v11"
API_PARAMETERS = {
"omit_unselected_resource_names": "true"
From dbfccac602d21014413b06765fcbea9fff418426 Mon Sep 17 00:00:00 2001
From: Dylan Sprayberry <28106103+dsprayberry@users.noreply.github.com>
Date: Wed, 25 Jan 2023 15:21:31 -0500
Subject: [PATCH 61/69] Version bump and changelog for PR 79 (#80)
---
CHANGELOG.md | 5 +++++
setup.py | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3956e5e..000f858 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v1.3.4
+ * Updates API Version to 11
+ * Updates pkg version to 17.0.0
+ * [#79](https://github.com/singer-io/tap-google-ads/pull/79)
+
## v1.3.3
* Update applicable core streams to use limit clause. Updates tests [#68](https://github.com/singer-io/tap-google-ads/pull/68)
diff --git a/setup.py b/setup.py
index 87b3a4f..5dc21c8 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.3.3',
+ version='1.3.4',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From c61ac633c71770bc812bb868ba5d5612ad86e21c Mon Sep 17 00:00:00 2001
From: Andy Lu
Date: Wed, 22 Feb 2023 09:45:14 -0500
Subject: [PATCH 62/69] TDL-21689 and TDL-21756 Bump to v12 (#76)
* Bump google-ads client library to the minimum package version that
supports the v12 api
See https://developers.google.com/google-ads/api/docs/client-libs#python
for more details
* Update API_VERSION
* Remove fields
https://developers.google.com/google-ads/api/docs/diff-tool/v12/versus-v11/diffs/common/ad_type_infos
---------
Co-authored-by: Dylan Sprayberry <28106103+dsprayberry@users.noreply.github.com>
---
setup.py | 5 +++--
tap_google_ads/report_definitions.py | 10 ----------
tap_google_ads/streams.py | 2 +-
3 files changed, 4 insertions(+), 13 deletions(-)
diff --git a/setup.py b/setup.py
index 5dc21c8..19f0eef 100644
--- a/setup.py
+++ b/setup.py
@@ -13,8 +13,9 @@
'singer-python==5.12.2',
'requests==2.26.0',
'backoff==1.8.0',
- 'google-ads==17.0.0',
- 'protobuf==3.20.0',
+ 'google-ads==19.0.0',
+ 'protobuf==4.21.5',
+
# Necessary to handle gRPC exceptions properly, documented
# in an issue here: https://github.com/googleapis/python-api-core/issues/301
'grpcio-status==1.44.0',
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index 90be94f..5f2b7ea 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -242,16 +242,6 @@
"ad_group_ad.ad.expanded_text_ad.path2",
"ad_group_ad.ad.final_mobile_urls",
"ad_group_ad.ad.final_urls",
- "ad_group_ad.ad.gmail_ad.header_image",
- "ad_group_ad.ad.gmail_ad.marketing_image",
- "ad_group_ad.ad.gmail_ad.marketing_image_description",
- "ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text",
- "ad_group_ad.ad.gmail_ad.marketing_image_display_call_to_action.text_color",
- "ad_group_ad.ad.gmail_ad.marketing_image_headline",
- "ad_group_ad.ad.gmail_ad.teaser.business_name",
- "ad_group_ad.ad.gmail_ad.teaser.description",
- "ad_group_ad.ad.gmail_ad.teaser.headline",
- "ad_group_ad.ad.gmail_ad.teaser.logo_image",
"ad_group_ad.ad.id",
"ad_group_ad.ad.image_ad.image_url",
"ad_group_ad.ad.image_ad.mime_type",
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index a5d95bb..2b44e10 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -14,7 +14,7 @@
LOGGER = singer.get_logger()
-API_VERSION = "v11"
+API_VERSION = "v12"
API_PARAMETERS = {
"omit_unselected_resource_names": "true"
From 9d92126ab1fccb6162987f78b4daba13ed2d8a56 Mon Sep 17 00:00:00 2001
From: Dylan Sprayberry <28106103+dsprayberry@users.noreply.github.com>
Date: Wed, 22 Feb 2023 09:47:15 -0500
Subject: [PATCH 63/69] Changelog entry and version bump for PR 76 (#78)
---
CHANGELOG.md | 6 ++++++
setup.py | 2 +-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 000f858..94b5c29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## v1.4.0
+ * Updates API version to 12
+ * Updates pkg version to 19.0.0
+ * Removes `gmail_ad` fields from `ad_performance_report` as they are no longer available after API version bump.
+ * [#76](https://github.com/singer-io/tap-google-ads/pull/76)
+
## v1.3.4
* Updates API Version to 11
* Updates pkg version to 17.0.0
diff --git a/setup.py b/setup.py
index 19f0eef..8788d05 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.3.4',
+ version='1.4.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
From ba831519478a7f93fe5d59c2ef602dd36a03ec22 Mon Sep 17 00:00:00 2001
From: Vishal
Date: Thu, 27 Apr 2023 15:58:35 +0530
Subject: [PATCH 64/69] google-ads updated api version to V13 (#82)
* updated api version to V13
* updated changelog
---
CHANGELOG.md | 5 +++++
setup.py | 6 +++---
tap_google_ads/streams.py | 2 +-
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94b5c29..096e0ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,9 @@
# Changelog
+## v1.5.0
+ * Updates API version to 13
+ * Updates pkg version to 21.0.0
+ * [#82](https://github.com/singer-io/tap-google-ads/pull/82)
+
## v1.4.0
* Updates API version to 12
diff --git a/setup.py b/setup.py
index 8788d05..cac6f21 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.4.0',
+ version='1.5.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
@@ -13,8 +13,8 @@
'singer-python==5.12.2',
'requests==2.26.0',
'backoff==1.8.0',
- 'google-ads==19.0.0',
- 'protobuf==4.21.5',
+ 'google-ads==21.0.0',
+ 'protobuf==4.22.3',
# Necessary to handle gRPC exceptions properly, documented
# in an issue here: https://github.com/googleapis/python-api-core/issues/301
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 2b44e10..53319cc 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -14,7 +14,7 @@
LOGGER = singer.get_logger()
-API_VERSION = "v12"
+API_VERSION = "v13"
API_PARAMETERS = {
"omit_unselected_resource_names": "true"
From 34b05a565f84a784262ced6be294d94d658ae0e0 Mon Sep 17 00:00:00 2001
From: rdeshmukh15 <107538720+rdeshmukh15@users.noreply.github.com>
Date: Wed, 13 Dec 2023 16:18:57 +0530
Subject: [PATCH 65/69] api version changes to v15 (#86)
* api version changes
* removed segments.product_bidding_category_level1
* removed segments.product_bidding_category_level2,3,4 and 5
* adding again product_bidding_category_level fields
* product_bidding_category_level... replaced with product_category_level..
---
CHANGELOG.md | 6 ++++++
setup.py | 6 +++---
tap_google_ads/report_definitions.py | 10 +++++-----
tap_google_ads/streams.py | 2 +-
4 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 096e0ce..5ec0821 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,10 @@
# Changelog
+
+## v1.6.0
+ * Updates API version to 15
+ * Updates pkg version to 22.1.0
+ * [#86](https://github.com/singer-io/tap-google-ads/pull/86)
+
## v1.5.0
* Updates API version to 13
* Updates pkg version to 21.0.0
diff --git a/setup.py b/setup.py
index cac6f21..362097a 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.5.0',
+ version='1.6.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
@@ -13,8 +13,8 @@
'singer-python==5.12.2',
'requests==2.26.0',
'backoff==1.8.0',
- 'google-ads==21.0.0',
- 'protobuf==4.22.3',
+ 'google-ads==22.1.0',
+ 'protobuf==4.24.4',
# Necessary to handle gRPC exceptions properly, documented
# in an issue here: https://github.com/googleapis/python-api-core/issues/301
diff --git a/tap_google_ads/report_definitions.py b/tap_google_ads/report_definitions.py
index 5f2b7ea..6f5f9a8 100644
--- a/tap_google_ads/report_definitions.py
+++ b/tap_google_ads/report_definitions.py
@@ -1729,11 +1729,11 @@
"segments.external_conversion_source",
"segments.month",
"segments.product_aggregator_id",
- "segments.product_bidding_category_level1",
- "segments.product_bidding_category_level2",
- "segments.product_bidding_category_level3",
- "segments.product_bidding_category_level4",
- "segments.product_bidding_category_level5",
+ "segments.product_category_level1",
+ "segments.product_category_level2",
+ "segments.product_category_level3",
+ "segments.product_category_level4",
+ "segments.product_category_level5",
"segments.product_brand",
"segments.product_channel",
"segments.product_channel_exclusivity",
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 53319cc..538cf64 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -14,7 +14,7 @@
LOGGER = singer.get_logger()
-API_VERSION = "v13"
+API_VERSION = "v15"
API_PARAMETERS = {
"omit_unselected_resource_names": "true"
From d5abdf2c070a9e740d5dbb225be76668e2730c45 Mon Sep 17 00:00:00 2001
From: Leslie VanDeMark <38043390+leslievandemark@users.noreply.github.com>
Date: Tue, 23 Jan 2024 15:53:42 -0500
Subject: [PATCH 66/69] Python upgrade 3.11.7 (#88)
* test on python 3.11
* bump backoff in setup
* version bump and changelog update
* keep on standard base image [skip ci]
-----------------------------
---
.circleci/config.yml | 7 +++----
CHANGELOG.md | 3 +++
setup.py | 6 +++---
3 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 43a11ec..75b3501 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -21,7 +21,7 @@ jobs:
command: |
python3 -mvenv /usr/local/share/virtualenvs/tap-google-ads
source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- pip install 'pip==21.1.3'
+ pip install 'pip==23.3.2'
pip install 'setuptools==56.0.0'
pip install .[dev]
- slack/notify-on-failure:
@@ -57,9 +57,8 @@ jobs:
name: 'Run Unit Tests'
command: |
source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- pip install nose coverage
- nosetests --with-coverage --cover-erase --cover-package=tap_google_ads --cover-html-dir=htmlcov tests/unittests
- coverage html
+ pip install nose2
+ nose2 -v -s tests/unittests
- store_test_results:
path: test_output/report.xml
- store_artifacts:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ec0821..509dff4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## v1.7.0
+ * Run on python 3.11.7 [#88](https://github.com/singer-io/tap-google-ads/pull/88)
+
## v1.6.0
* Updates API version to 15
* Updates pkg version to 22.1.0
diff --git a/setup.py b/setup.py
index 362097a..d3265d0 100644
--- a/setup.py
+++ b/setup.py
@@ -3,16 +3,16 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.6.0',
+ version='1.7.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
classifiers=['Programming Language :: Python :: 3 :: Only'],
py_modules=['tap_google_ads'],
install_requires=[
- 'singer-python==5.12.2',
+ 'singer-python==6.0.0',
'requests==2.26.0',
- 'backoff==1.8.0',
+ 'backoff==2.2.1',
'google-ads==22.1.0',
'protobuf==4.24.4',
From 59b44f3b685fab93ff29c1f69457871d968ea127 Mon Sep 17 00:00:00 2001
From: Eivin Giske Skaaren
Date: Tue, 3 Sep 2024 12:49:47 +0200
Subject: [PATCH 67/69] Enable copilot usage in PR template according to Qlik
policy
---
.github/pull_request_template.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 858a40e..964dcda 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -9,3 +9,7 @@
# Rollback steps
- revert this branch
+
+#### AI generated code
+https://internal.qlik.dev/general/ways-of-working/code-reviews/#guidelines-for-ai-generated-code
+- [ ] this PR has been written with the help of GitHub Copilot or another generative AI tool
From 0f0bf57a1baa6ad1c719e58f3f8a47b4a5d35518 Mon Sep 17 00:00:00 2001
From: rdeshmukh15 <107538720+rdeshmukh15@users.noreply.github.com>
Date: Tue, 17 Sep 2024 13:13:31 +0530
Subject: [PATCH 68/69] Upgrade to Google Ads API v17 (#93)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* hanges to upgrade to v17
* bump version changes
---------
Co-authored-by: “rdeshmukh15” <“rutuja.deshmukh@qlik.com”>
---
CHANGELOG.md | 5 +++++
setup.py | 8 ++++----
tap_google_ads/streams.py | 2 +-
3 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 509dff4..bf3a65b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v1.8.0
+ * Updates API version to 17
+ * Updates pkg version to 25.0.0
+ * [#93](https://github.com/singer-io/tap-google-ads/pull/93)
+
## v1.7.0
* Run on python 3.11.7 [#88](https://github.com/singer-io/tap-google-ads/pull/88)
diff --git a/setup.py b/setup.py
index d3265d0..66b3f1b 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
setup(name='tap-google-ads',
- version='1.7.0',
+ version='1.8.0',
description='Singer.io tap for extracting data from the Google Ads API',
author='Stitch',
url='http://singer.io',
@@ -13,12 +13,12 @@
'singer-python==6.0.0',
'requests==2.26.0',
'backoff==2.2.1',
- 'google-ads==22.1.0',
- 'protobuf==4.24.4',
+ 'google-ads==25.0.0',
+ 'protobuf==5.28.0',
# Necessary to handle gRPC exceptions properly, documented
# in an issue here: https://github.com/googleapis/python-api-core/issues/301
- 'grpcio-status==1.44.0',
+ 'grpcio-status==1.66.1',
],
extras_require= {
'dev': [
diff --git a/tap_google_ads/streams.py b/tap_google_ads/streams.py
index 538cf64..89c6a90 100644
--- a/tap_google_ads/streams.py
+++ b/tap_google_ads/streams.py
@@ -14,7 +14,7 @@
LOGGER = singer.get_logger()
-API_VERSION = "v15"
+API_VERSION = "v17"
API_PARAMETERS = {
"omit_unselected_resource_names": "true"
From b0505c8a4d8ce073c5cb1e12658dfaa295f7fd06 Mon Sep 17 00:00:00 2001
From: Guilherme
Date: Wed, 11 Dec 2024 11:31:59 -0300
Subject: [PATCH 69/69] UPDATE: Remove circleci [TECN-9185].
---
.circleci/config.yml | 139 -------------------------------------------
1 file changed, 139 deletions(-)
delete mode 100644 .circleci/config.yml
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 75b3501..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,139 +0,0 @@
-version: 2.1
-orbs:
- slack: circleci/slack@3.4.2
-
-executors:
- docker-executor:
- docker:
- - image: 218546966473.dkr.ecr.us-east-1.amazonaws.com/circle-ci:stitch-tap-tester
-
-jobs:
- build:
- executor: docker-executor
- steps:
- - run: echo 'CI done'
- ensure_env:
- executor: docker-executor
- steps:
- - checkout
- - run:
- name: 'Setup virtual env'
- command: |
- python3 -mvenv /usr/local/share/virtualenvs/tap-google-ads
- source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- pip install 'pip==23.3.2'
- pip install 'setuptools==56.0.0'
- pip install .[dev]
- - slack/notify-on-failure:
- only_for_branches: main
- - persist_to_workspace:
- root: /usr/local/share/virtualenvs
- paths:
- - tap-google-ads
- run_pylint:
- executor: docker-executor
- steps:
- - checkout
- - attach_workspace:
- at: /usr/local/share/virtualenvs
- - run:
- name: 'Run pylint'
- command: |
- source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
- source dev_env.sh
- echo "$PYLINT_DISABLE_LIST"
- pylint tap_google_ads --disable "$PYLINT_DISABLE_LIST"
- - slack/notify-on-failure:
- only_for_branches: main
-
- run_unit_tests:
- executor: docker-executor
- steps:
- - checkout
- - attach_workspace:
- at: /usr/local/share/virtualenvs
- - run:
- name: 'Run Unit Tests'
- command: |
- source /usr/local/share/virtualenvs/tap-google-ads/bin/activate
- pip install nose2
- nose2 -v -s tests/unittests
- - store_test_results:
- path: test_output/report.xml
- - store_artifacts:
- path: htmlcov
- - slack/notify-on-failure:
- only_for_branches: main
-
- run_integration_tests:
- executor: docker-executor
- parallelism: 19
- steps:
- - checkout
- - attach_workspace:
- at: /usr/local/share/virtualenvs
- - run:
- name: 'Run Integration Tests'
- no_output_timeout: 30m
- command: |
- aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh
- source dev_env.sh
- mkdir /tmp/${CIRCLE_PROJECT_REPONAME}
- export STITCH_CONFIG_DIR=/tmp/${CIRCLE_PROJECT_REPONAME}
- source /usr/local/share/virtualenvs/tap-tester/bin/activate
- circleci tests glob "tests/*.py" | circleci tests split > ./tests-to-run
- if [ -s ./tests-to-run ]; then
- for test_file in $(cat ./tests-to-run)
- do
- run-test --tap=${CIRCLE_PROJECT_REPONAME} $test_file
- done
- fi
- - slack/notify-on-failure:
- only_for_branches: main
- - store_artifacts:
- path: /tmp/tap-google-ads
-
-workflows:
- version: 2
- commit: &commit_jobs
- jobs:
- - ensure_env:
- context:
- - circleci-user
- - tier-1-tap-user
- - run_pylint:
- context:
- - circleci-user
- - tier-1-tap-user
- requires:
- - ensure_env
- - run_unit_tests:
- context:
- - circleci-user
- - tier-1-tap-user
- requires:
- - ensure_env
- - run_integration_tests:
- context:
- - circleci-user
- - tier-1-tap-user
- requires:
- - ensure_env
- - build:
- context:
- - circleci-user
- - tier-1-tap-user
- requires:
- - run_pylint
- - run_unit_tests
- - run_integration_tests
- build_daily:
- <<: *commit_jobs
- triggers:
- - schedule:
- cron: "0 3 * * *"
- filters:
- branches:
- only:
- - main